onyx-tasks/apps/tauri/src/lib/components/DateTimePicker.svelte
Claude c952156491
refactor(date-picker): group selected-state declarations up top
selectedYear/selectedMonth were declared below selectDay, which writes
to them, and below isToday, which is declared nearby. Runtime worked
because the assignments only run on user click (after script init), but
the split made the initialization order confusing. Group all $state
fields at the top of the script.
2026-04-19 07:13:29 +00:00

193 lines
7.6 KiB
Svelte

<script lang="ts">
let { value = null, has_time = false, onchange, onclose }: {
value: string | null;
has_time: boolean;
onchange: (iso: string | null, has_time: boolean) => void;
onclose: () => void;
} = $props();
const DAY_NAMES = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
let now = new Date();
let existing = value ? new Date(value) : null;
let viewYear = $state(existing ? existing.getFullYear() : now.getFullYear());
let viewMonth = $state(existing ? existing.getMonth() : now.getMonth());
let selectedDay = $state(existing ? existing.getDate() : now.getDate());
let selectedYear = $state(existing ? existing.getFullYear() : now.getFullYear());
let selectedMonth = $state(existing ? existing.getMonth() : now.getMonth());
let includeTime = $state(has_time);
let selectedHour = $state(existing ? existing.getHours() : now.getHours());
let selectedMinute = $state(existing ? existing.getMinutes() : 0);
let visible = $state(false);
let todayStr = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`;
let daysInMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
let firstDayOfWeek = $derived(new Date(viewYear, viewMonth, 1).getDay());
let monthLabel = $derived(new Date(viewYear, viewMonth).toLocaleDateString(undefined, { month: "long", year: "numeric" }));
let calendarCells = $derived.by(() => {
const cells: (number | null)[] = [];
for (let i = 0; i < firstDayOfWeek; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
return cells;
});
requestAnimationFrame(() => { visible = true; });
function dismiss() {
visible = false;
setTimeout(onclose, 200);
}
function prevMonth() {
if (viewMonth === 0) { viewMonth = 11; viewYear--; }
else viewMonth--;
}
function nextMonth() {
if (viewMonth === 11) { viewMonth = 0; viewYear++; }
else viewMonth++;
}
function selectDay(day: number) {
selectedDay = day;
selectedYear = viewYear;
selectedMonth = viewMonth;
}
function isToday(day: number): boolean {
return `${viewYear}-${viewMonth + 1}-${day}` === todayStr;
}
function isSelected(day: number): boolean {
return selectedDay === day && selectedYear === viewYear && selectedMonth === viewMonth;
}
function done() {
const h = includeTime ? selectedHour : 0;
const m = includeTime ? selectedMinute : 0;
// Commit based on the last-selected year/month, not the currently-viewed
// ones — users can navigate months after selecting a day without
// accidentally shifting the chosen date to the viewed month.
const iso = new Date(selectedYear, selectedMonth, selectedDay, h, m).toISOString();
onchange(iso, includeTime);
dismiss();
}
function clear() {
onchange(null, false);
dismiss();
}
</script>
<!-- Wrapper: backdrop click dismisses, sheet click stops propagation -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 z-40 transition-opacity duration-200 {visible ? 'opacity-100' : 'opacity-0'}"
style="background: rgba(0,0,0,0.4)"
onclick={dismiss}
onkeydown={(e) => { if (e.key === "Escape") dismiss(); }}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute bottom-0 left-0 right-0 rounded-t-2xl bg-surface-light shadow-xl transition-transform duration-200 ease-out dark:bg-card-dark {visible ? 'translate-y-0' : 'translate-y-full'}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between px-4 pt-3 pb-2">
<span class="text-sm font-semibold">Date & Time</span>
<button onclick={done} class="text-sm font-medium text-primary hover:opacity-70">Done</button>
</div>
<!-- Month navigation -->
<div class="flex items-center justify-between px-4 py-2">
<span class="text-sm font-medium">{monthLabel}</span>
<div class="flex gap-1">
<button onclick={prevMonth} class="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" />
</svg>
</button>
<button onclick={nextMonth} class="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" />
</svg>
</button>
</div>
</div>
<!-- Day names -->
<div class="grid grid-cols-7 px-4">
{#each DAY_NAMES as name}
<div class="py-1 text-center text-xs font-medium opacity-40">{name}</div>
{/each}
</div>
<!-- Calendar grid -->
<div class="grid grid-cols-7 content-start px-4 pb-2" style="height: 192px;">
{#each calendarCells as day}
{#if day === null}
<div></div>
{:else}
<button
onclick={() => selectDay(day)}
class="mx-auto flex h-8 w-8 items-center justify-center rounded-full text-sm transition-colors
{isSelected(day) ? 'bg-primary text-white' : ''}
{isToday(day) && !isSelected(day) ? 'font-bold text-primary' : ''}
{!isSelected(day) && !isToday(day) ? 'hover:bg-black/5 dark:hover:bg-white/10' : ''}"
>
{day}
</button>
{/if}
{/each}
</div>
<!-- Time section -->
<div class="flex items-center gap-3 border-t border-border-light px-4 py-3 dark:border-border-dark">
{#if includeTime}
<span class="text-sm opacity-50">Time</span>
<div class="flex items-center gap-1">
<select
bind:value={selectedHour}
class="appearance-none rounded-lg border border-border-light bg-surface-light px-2 py-1 text-sm text-text-light outline-none dark:border-border-dark dark:bg-surface-dark dark:text-text-dark"
>
{#each Array(24) as _, h}
<option value={h}>{String(h).padStart(2, "0")}</option>
{/each}
</select>
<span class="text-sm opacity-40">:</span>
<select
bind:value={selectedMinute}
class="appearance-none rounded-lg border border-border-light bg-surface-light px-2 py-1 text-sm text-text-light outline-none dark:border-border-dark dark:bg-surface-dark dark:text-text-dark"
>
{#each [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55] as m}
<option value={m}>{String(m).padStart(2, "0")}</option>
{/each}
</select>
</div>
<button onclick={() => (includeTime = false)} class="ml-auto opacity-40 hover:opacity-80">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
{:else}
<button
onclick={() => (includeTime = true)}
class="text-sm opacity-50 hover:opacity-80"
>
Set time
</button>
{/if}
</div>
<!-- Clear button -->
{#if value}
<div class="border-t border-border-light px-4 py-3 dark:border-border-dark">
<button onclick={clear} class="text-sm text-danger hover:opacity-70">Clear date</button>
</div>
{/if}
<div style="height: max(0.75rem, var(--safe-bottom))"></div>
</div>
</div>