onyx-tasks/apps/tauri/src/lib/components/DateTimePicker.svelte
Tristan Michael 908ac41a6c DateTimePicker: clear time and emit done on unset
Users couldn't clear the time part of a task's due date because the
"clear time" button only toggled includeTime=false without signalling
the picker to finish. Call done() when clearing the time so the picker
closes/emits the updated value, ensuring the task's has_time state is
updated and the UI reflects a date-only value.
2026-04-01 01:29:08 -07:00

188 lines
7.2 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 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()}-${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;
}
function isToday(day: number): boolean {
return `${viewYear}-${viewMonth}-${day}` === todayStr;
}
function isSelected(day: number): boolean {
return selectedDay === day && (!value || (() => {
const v = new Date(value);
return v.getFullYear() === viewYear && v.getMonth() === viewMonth;
})());
}
function done() {
const h = includeTime ? selectedHour : 0;
const m = includeTime ? selectedMinute : 0;
const iso = new Date(viewYear, viewMonth, 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 px-4 pb-2">
{#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
{selectedDay === day ? 'bg-primary text-white' : ''}
{isToday(day) && selectedDay !== day ? 'font-bold text-primary' : ''}
{selectedDay !== 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; done(); }} 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>
</div>