115 lines
3.9 KiB
Svelte
115 lines
3.9 KiB
Svelte
<script lang="ts" module>
|
|
export type DayTone = "none" | "inside" | "start" | "end" | "solo";
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
|
import { fromKey, localKey } from "../lib/due";
|
|
import { WEEKDAYS } from "../lib/vocab";
|
|
import { cn } from "../lib/utils";
|
|
|
|
interface Props {
|
|
anchor: string | null;
|
|
onhover?: (key: string) => void;
|
|
onpick: (key: string) => void;
|
|
/** The store's key for today, so the mark moves with the clock. */
|
|
today?: string;
|
|
tone: (key: string) => DayTone;
|
|
}
|
|
|
|
let { anchor, tone, onpick, onhover, today }: Props = $props();
|
|
|
|
const DAY = 86_400_000;
|
|
const monthName = new Intl.DateTimeFormat("en-US", { month: "long" });
|
|
const todayKey = $derived(today ?? localKey(new Date()));
|
|
|
|
let picked = $state<Date | null>(null);
|
|
|
|
const month = $derived.by(() => {
|
|
if (picked) return picked;
|
|
const base = anchor ? fromKey(anchor) : new Date();
|
|
return new Date(base.getFullYear(), base.getMonth(), 1);
|
|
});
|
|
|
|
const days = $derived.by(() => {
|
|
const offset = (month.getDay() + 6) % 7;
|
|
const start = new Date(month.getTime() - offset * DAY);
|
|
start.setHours(0, 0, 0, 0);
|
|
return Array.from({ length: 42 }, (_, index) => {
|
|
const date = new Date(start.getTime() + index * DAY);
|
|
date.setHours(0, 0, 0, 0);
|
|
return {
|
|
date,
|
|
inMonth: date.getMonth() === month.getMonth(),
|
|
key: localKey(date),
|
|
};
|
|
});
|
|
});
|
|
|
|
function shiftMonth(delta: number) {
|
|
picked = new Date(month.getFullYear(), month.getMonth() + delta, 1);
|
|
}
|
|
</script>
|
|
|
|
<div class="w-60 select-none">
|
|
<div class="flex items-center gap-1 px-1 pb-1">
|
|
<button
|
|
aria-label="Previous month"
|
|
class="grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
onclick={() => shiftMonth(-1)}
|
|
type="button"
|
|
>
|
|
<ChevronLeft aria-hidden="true" class="size-3.5" />
|
|
</button>
|
|
<span class="flex-1 text-center font-medium text-xs first-letter:uppercase">
|
|
{monthName.format(month)}
|
|
{month.getFullYear()}
|
|
</span>
|
|
<button
|
|
aria-label="Next month"
|
|
class="grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
onclick={() => shiftMonth(1)}
|
|
type="button"
|
|
>
|
|
<ChevronRight aria-hidden="true" class="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-7 pb-0.5">
|
|
{#each WEEKDAYS as weekday (weekday)}
|
|
<span class="py-0.5 text-center text-[10px] text-muted-foreground">
|
|
{weekday}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="grid grid-cols-7 gap-y-px">
|
|
{#each days as day (day.key)}
|
|
{@const shape = tone(day.key)}
|
|
<button
|
|
aria-label={day.date.toLocaleDateString("en-US")}
|
|
aria-pressed={shape !== "none"}
|
|
class={cn(
|
|
"h-8 text-xs outline-none transition-colors tabular focus-visible:ring-2 focus-visible:ring-ring",
|
|
day.inMonth ? "text-foreground" : "text-muted-foreground/50",
|
|
shape === "none" && "rounded-md hover:bg-muted",
|
|
shape === "inside" && "bg-secondary",
|
|
shape !== "none" &&
|
|
shape !== "inside" &&
|
|
"bg-primary font-medium text-primary-foreground",
|
|
shape === "solo" && "rounded-md",
|
|
shape === "start" && "rounded-l-md",
|
|
shape === "end" && "rounded-r-md",
|
|
day.key === todayKey && shape === "none" && "font-medium text-signal",
|
|
)}
|
|
onclick={() => onpick(day.key)}
|
|
onmouseenter={() => onhover?.(day.key)}
|
|
type="button"
|
|
>
|
|
{day.date.getDate()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|