Files
beaver-calendar/src/ui/day-grid.svelte
T

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>