Masked date input whose popover calendar doubles as a moon-phase almanac: every day cell carries a canvas moon with a real synodic terminator, the field icon tracks the focused date's phase live, and committing runs an eclipse transit across the chosen numeral.
npx shadcn add https://design.helpmarq.com /r/date-picker-moon.jsonregistry/core/date-picker-moon/component.tsx"use client";
import {
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
// ---------------------------------------------------------------------------
// TerminatorDateField — masked MM/DD/YYYY date input whose popover calendar
// doubles as a moon-phase almanac. Every one of the 42 day cells carries a
// 14px canvas moon whose terminator curve comes from the real mean synodic
// month (29.530588853 d, reference new moon 2000-01-06 18:14 UTC); a 20px
// canvas in the input's trailing slot mirrors the focused date's phase live
// (150ms crossfade), and committing a date sweeps an eclipse shadow-disc
// across the chosen numeral (420ms ease-out-expo, cleared and redrawn every
// frame — zero accumulation). ONE canvas sits behind the whole grid; glyph
// positions derive from measured cell rects after layout settles. All ink is
// getComputedStyle-token-derived at mount and re-derived on documentElement
// class changes. rAF runs only during crossfade/transit; the static almanac
// costs zero frames.
// ---------------------------------------------------------------------------
const SYNODIC = 29.530588853; // mean synodic month, days
const EPOCH_MS = Date.UTC(2000, 0, 6, 18, 14); // reference new moon, UTC
const DAY_MS = 86400000;
const ICON = 20; // trailing-slot canvas, px
const ICON_R = 8;
const MOON_R = 7; // 14px grid discs
const NUM_Y = 13; // numeral center within a 44px cell
const MOON_Y = 31; // moon center within a 44px cell
const CROSSFADE_MS = 150;
const TRANSIT_MS = 420;
const PHASE_NAMES = [
"New moon",
"Waxing crescent",
"First quarter",
"Waxing gibbous",
"Full moon",
"Waning gibbous",
"Last quarter",
"Waning crescent",
] as const;
const WEEKDAYS = [
["Su", "Sunday"],
["Mo", "Monday"],
["Tu", "Tuesday"],
["We", "Wednesday"],
["Th", "Thursday"],
["Fr", "Friday"],
["Sa", "Saturday"],
] as const;
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const;
type RGB = readonly [number, number, number];
interface Ink {
fg: RGB;
bd: RGB;
bg: RGB;
}
interface CellGeom {
x: number;
y: number;
w: number;
h: number;
cx: number;
numY: number;
moonY: number;
f: number; // phase fraction for this cell's date
dim: boolean; // outside the viewed month
}
interface GridGeom {
w: number;
h: number;
dpr: number;
cells: (CellGeom | null)[];
}
function parseColor(raw: string): RGB | null {
const s = raw.trim();
if (s.startsWith("#")) {
const hex = s.slice(1);
if (hex.length === 3) {
const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);
const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);
const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return Number.isNaN(r + g + b) ? null : [r, g, b];
}
return null;
}
const m = s.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
const rgba = (c: RGB, a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
/** phase fraction f in [0,1): 0 = new, 0.5 = full. Sampled at local noon. */
function phaseFraction(d: Date): number {
const noon = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 12);
const days = (noon.getTime() - EPOCH_MS) / DAY_MS;
return (((days % SYNODIC) + SYNODIC) % SYNODIC) / SYNODIC;
}
const phaseIndex = (f: number) => Math.round(f * 8) % 8;
const illumination = (f: number) => (1 - Math.cos(2 * Math.PI * f)) / 2;
/**
* Disc stroked --border; lit region --foreground at 85% alpha. Terminator is
* a semi-ellipse with x-radius r*|cos(2πf)|; the lit limb sits right while
* waxing (f < 0.5) and left while waning, ellipse sweep flips crescent/gibbous.
*/
function drawMoon(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
r: number,
f: number,
ink: Ink,
alpha: number
) {
const phi = 2 * Math.PI * f;
const c = Math.cos(phi);
const k = (1 - c) / 2;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = rgba(ink.bd, 0.9 * alpha);
ctx.lineWidth = 1;
ctx.stroke();
if (k < 0.015) return; // new moon — outline only
const ri = r - 0.5;
ctx.beginPath();
if (k > 0.985) {
ctx.arc(cx, cy, ri, 0, Math.PI * 2);
} else {
const waxing = f < 0.5;
const rx = Math.min(ri, Math.max(0.02, ri * Math.abs(c)));
// lit limb: right semicircle for waxing, left for waning (top -> bottom)
ctx.arc(cx, cy, ri, -Math.PI / 2, Math.PI / 2, !waxing);
// terminator: bottom -> top, bulging toward or away from the lit limb
ctx.ellipse(cx, cy, rx, ri, 0, Math.PI / 2, -Math.PI / 2, waxing ? c > 0 : c < 0);
}
ctx.fillStyle = rgba(ink.fg, 0.85 * alpha);
ctx.fill();
}
const easeOutExpo = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t));
const pad2 = (n: number) => String(n).padStart(2, "0");
const pad4 = (n: number) => String(n).padStart(4, "0");
const fmt = (d: Date) =>
`${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}/${pad4(d.getFullYear())}`;
const addDays = (d: Date, n: number) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
function addMonths(d: Date, n: number) {
const y = d.getFullYear();
const m = d.getMonth() + n;
const last = new Date(y, m + 1, 0).getDate();
return new Date(y, m, Math.min(d.getDate(), last));
}
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
/** 42-cell month sheet starting on the Sunday on/before the 1st. */
function cellDates(y: number, m: number): Date[] {
const offset = new Date(y, m, 1).getDay();
return Array.from({ length: 42 }, (_, i) => new Date(y, m, i + 1 - offset));
}
/** MM/DD/YYYY mask: digits only, slashes inserted as you type. */
function maskText(raw: string): string {
const d = raw.replace(/\D/g, "").slice(0, 8);
let out = d.slice(0, 2);
if (d.length > 2) out += "/" + d.slice(2, 4);
if (d.length > 4) out += "/" + d.slice(4, 8);
return out;
}
function parseMasked(text: string): Date | null {
const m = text.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return null;
const mo = Number(m[1]);
const da = Number(m[2]);
const yr = Number(m[3]);
if (yr < 1000 || yr > 9999) return null; // reject non-4-digit-real years
const d = new Date(yr, mo - 1, da);
return d.getFullYear() === yr && d.getMonth() === mo - 1 && d.getDate() === da
? d
: null;
}
export function TerminatorDateField({
value,
defaultValue = null,
onValueChange,
label = "Date",
disabled = false,
className = "",
}: {
/** controlled selected date; omit for uncontrolled */
value?: Date | null;
defaultValue?: Date | null;
onValueChange?: (date: Date) => void;
label?: string;
disabled?: boolean;
className?: string;
}) {
const uid = useId();
const inputId = `${uid}-input`;
const helperId = `${uid}-helper`;
const popId = `${uid}-pop`;
const isControlled = value !== undefined;
const [internal, setInternal] = useState<Date | null>(defaultValue);
const selected = isControlled ? (value ?? null) : internal;
const [text, setText] = useState(() => (selected ? fmt(selected) : ""));
const [open, setOpen] = useState(false);
const [focusedDate, setFocusedDate] = useState<Date>(
() => selected ?? new Date()
);
const [view, setView] = useState(() => {
const b = selected ?? new Date();
return { y: b.getFullYear(), m: b.getMonth() };
});
const rootRef = useRef<HTMLDivElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const iconCanvasRef = useRef<HTMLCanvasElement>(null);
const gridWrapRef = useRef<HTMLDivElement>(null);
const gridCanvasRef = useRef<HTMLCanvasElement>(null);
const cellRefs = useRef<(HTMLButtonElement | null)[]>([]);
const geomRef = useRef<GridGeom | null>(null);
const justOpenedRef = useRef(false);
const closeTimerRef = useRef(0);
const pendingCaretRef = useRef<number | null>(null);
const animRef = useRef<{
iconF: number | null;
iconAnim: { from: number; to: number; start: number } | null;
transit: { idx: number; start: number } | null;
}>({ iconF: null, iconAnim: null, transit: null });
const engineRef = useRef<{
setIcon: (f: number) => void;
startTransit: (idx: number) => boolean;
redrawGrid: () => void;
} | null>(null);
const cells = useMemo(() => cellDates(view.y, view.m), [view.y, view.m]);
const today = useMemo(() => new Date(), []);
const invalid = text.length === 10 && parseMasked(text) === null;
const previewDate = open
? focusedDate
: (parseMasked(text) ?? selected ?? today);
const previewF = phaseFraction(previewDate);
const previewName = PHASE_NAMES[phaseIndex(previewF)];
// -- engine: token ink, icon crossfade, grid redraw, eclipse transit -------
useEffect(() => {
const icon = iconCanvasRef.current;
const root = rootRef.current;
if (!icon || !root) return;
const ictx = icon.getContext("2d");
if (!ictx) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
const dpr = Math.min(2, window.devicePixelRatio || 1);
// <canvas> is a replaced element — size it explicitly, never via inset
icon.style.width = `${ICON}px`;
icon.style.height = `${ICON}px`;
icon.width = ICON * dpr;
icon.height = ICON * dpr;
const ink: Ink = {
fg: [237, 237, 237],
bd: [46, 46, 46],
bg: [10, 10, 10],
};
const derive = () => {
const cs = getComputedStyle(document.documentElement);
ink.fg = parseColor(cs.getPropertyValue("--foreground")) ?? ink.fg;
ink.bd = parseColor(cs.getPropertyValue("--border")) ?? ink.bd;
ink.bg = parseColor(cs.getPropertyValue("--background")) ?? ink.bg;
};
derive();
let raf = 0;
let visible = true;
const drawIcon = (from: number | null, to: number, t: number) => {
ictx.setTransform(dpr, 0, 0, dpr, 0, 0);
ictx.clearRect(0, 0, ICON, ICON);
if (from !== null && t < 1)
drawMoon(ictx, ICON / 2, ICON / 2, ICON_R, from, ink, 1 - t);
drawMoon(
ictx,
ICON / 2,
ICON / 2,
ICON_R,
to,
ink,
from !== null ? Math.min(1, t) : 1
);
};
const drawGridStatic = () => {
const canvas = gridCanvasRef.current;
const g = geomRef.current;
if (!canvas || !g) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(g.dpr, 0, 0, g.dpr, 0, 0);
ctx.clearRect(0, 0, g.w, g.h);
for (const c of g.cells) {
if (!c) continue;
drawMoon(ctx, c.cx, c.moonY, MOON_R, c.f, ink, c.dim ? 0.45 : 1);
}
};
// full clear + full redraw every transit frame — no accumulation
const drawGridFrame = (p: number, idx: number) => {
const canvas = gridCanvasRef.current;
const g = geomRef.current;
if (!canvas || !g) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
drawGridStatic();
const cell = g.cells[idx];
if (!cell) return;
const e = easeOutExpo(p);
const env = p < 0.15 ? p / 0.15 : p > 0.85 ? (1 - p) / 0.15 : 1;
const R = 10;
ctx.save();
ctx.beginPath();
ctx.rect(cell.x, cell.y, cell.w, cell.h);
ctx.clip();
ctx.globalAlpha = env;
// lit corona behind the numeral...
ctx.beginPath();
ctx.arc(cell.cx, cell.numY, R, 0, Math.PI * 2);
ctx.fillStyle = rgba(ink.fg, 0.22);
ctx.fill();
// ...eclipsed by a background-ink shadow disc sweeping across it
const sx = cell.cx - R * 2.6 + e * R * 5.2;
ctx.beginPath();
ctx.arc(sx, cell.numY, R + 1, 0, Math.PI * 2);
ctx.fillStyle = rgba(ink.bg, 1);
ctx.fill();
ctx.strokeStyle = rgba(ink.bd, 1);
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
};
const tick = (now: number) => {
raf = 0;
if (document.hidden || !visible) return; // parked; wake resumes
const A = animRef.current;
let active = false;
if (A.iconAnim) {
const t = Math.min(1, (now - A.iconAnim.start) / CROSSFADE_MS);
drawIcon(A.iconAnim.from, A.iconAnim.to, t);
if (t >= 1) A.iconAnim = null;
else active = true;
}
if (A.transit) {
const p = Math.min(1, (now - A.transit.start) / TRANSIT_MS);
drawGridFrame(p, A.transit.idx);
if (p >= 1) {
A.transit = null;
drawGridStatic();
} else active = true;
}
if (active) raf = requestAnimationFrame(tick);
};
const wake = () => {
if (!raf && !document.hidden && visible) raf = requestAnimationFrame(tick);
};
const setIcon = (f: number) => {
const A = animRef.current;
if (A.iconF === null || reduced || Math.abs(f - A.iconF) < 1e-4) {
A.iconF = f;
A.iconAnim = null;
drawIcon(null, f, 1);
return;
}
A.iconAnim = { from: A.iconF, to: f, start: performance.now() };
A.iconF = f;
wake();
};
const startTransit = (idx: number) => {
const g = geomRef.current;
if (reduced || !g || !g.cells[idx] || !gridCanvasRef.current)
return false;
animRef.current.transit = { idx, start: performance.now() };
wake();
return true;
};
engineRef.current = { setIcon, startTransit, redrawGrid: drawGridStatic };
// live theme re-derive: watch documentElement class flips
const mo = new MutationObserver(() => {
derive();
const A = animRef.current;
if (A.iconF !== null) drawIcon(null, A.iconF, 1);
drawGridStatic();
});
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible) wake();
});
io.observe(root);
const onVis = () => {
if (!document.hidden) wake();
};
document.addEventListener("visibilitychange", onVis);
return () => {
cancelAnimationFrame(raf);
mo.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
animRef.current.iconAnim = null;
animRef.current.transit = null;
engineRef.current = null;
};
}, []);
// trailing icon tracks the focused/typed date's phase
useEffect(() => {
engineRef.current?.setIcon(previewF);
}, [previewF]);
// -- grid canvas: measure real cell rects after layout settles, draw once --
useEffect(() => {
if (!open) {
geomRef.current = null;
return;
}
const wrap = gridWrapRef.current;
const canvas = gridCanvasRef.current;
if (!wrap || !canvas) return;
let raf1 = 0;
let raf2 = 0;
const measure = () => {
const rect = wrap.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) {
geomRef.current = null; // zero-size guard
return;
}
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.style.width = `${rect.width}px`; // explicit — replaced element
canvas.style.height = `${rect.height}px`;
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const gcells: (CellGeom | null)[] = new Array(42).fill(null);
cells.forEach((date, i) => {
const el = cellRefs.current[i];
if (!el) return;
const b = el.getBoundingClientRect();
const x = b.left - rect.left;
const y = b.top - rect.top;
gcells[i] = {
x,
y,
w: b.width,
h: b.height,
cx: x + b.width / 2,
numY: y + NUM_Y,
moonY: y + MOON_Y,
f: phaseFraction(date),
dim: date.getMonth() !== view.m,
};
});
geomRef.current = { w: rect.width, h: rect.height, dpr, cells: gcells };
engineRef.current?.redrawGrid();
};
// double rAF: let popover layout settle before reading cell offsets
raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(measure);
});
const ro = new ResizeObserver(() => {
cancelAnimationFrame(raf2);
raf2 = requestAnimationFrame(measure);
});
ro.observe(wrap);
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
ro.disconnect();
geomRef.current = null;
animRef.current.transit = null;
};
}, [open, cells, view.m]);
// roving focus follows the focused cell while the grid owns focus
useEffect(() => {
if (!open) return;
const idx = cells.findIndex((c) => sameDay(c, focusedDate));
if (idx < 0) return;
const el = cellRefs.current[idx];
if (!el) return;
const ae = document.activeElement;
const inGrid =
ae instanceof HTMLElement && ae.getAttribute("role") === "gridcell";
if (justOpenedRef.current || (popRef.current?.contains(ae) && inGrid)) {
el.focus();
justOpenedRef.current = false;
}
}, [open, focusedDate, cells]);
// outside click closes without stealing focus
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) close(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// sync typed text when a controlled value changes from outside
const controlledTime = isControlled ? (value ? value.getTime() : -1) : null;
useEffect(() => {
if (controlledTime === null) return;
setText(value ? fmt(value) : "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlledTime]);
useEffect(() => {
return () => window.clearTimeout(closeTimerRef.current);
}, []);
// restore caret after the mask reformats `text` — React resets the DOM
// value on every render, which otherwise collapses the caret to the end
useLayoutEffect(() => {
const pos = pendingCaretRef.current;
if (pos === null) return;
pendingCaretRef.current = null;
inputRef.current?.setSelectionRange(pos, pos);
}, [text]);
const close = (focusBack: boolean) => {
window.clearTimeout(closeTimerRef.current);
animRef.current.transit = null;
setOpen(false);
if (focusBack) inputRef.current?.focus();
};
const openPopover = () => {
if (disabled) return;
const base = parseMasked(text) ?? selected ?? new Date();
setFocusedDate(base);
setView({ y: base.getFullYear(), m: base.getMonth() });
justOpenedRef.current = true;
setOpen(true);
};
const moveFocus = (d: Date) => {
setFocusedDate(d);
setView({ y: d.getFullYear(), m: d.getMonth() });
};
const commit = (date: Date, idx: number | null) => {
if (!isControlled) setInternal(date);
onValueChange?.(date);
setText(fmt(date));
setFocusedDate(date);
if (open) {
if (idx !== null && engineRef.current?.startTransit(idx)) {
closeTimerRef.current = window.setTimeout(
() => close(true),
TRANSIT_MS + 60
);
} else close(true);
}
};
const onGridKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
let d: Date | null = null;
switch (e.key) {
case "ArrowLeft":
d = addDays(focusedDate, -1);
break;
case "ArrowRight":
d = addDays(focusedDate, 1);
break;
case "ArrowUp":
d = addDays(focusedDate, -7);
break;
case "ArrowDown":
d = addDays(focusedDate, 7);
break;
case "Home":
d = addDays(focusedDate, -focusedDate.getDay());
break;
case "End":
d = addDays(focusedDate, 6 - focusedDate.getDay());
break;
case "PageUp":
d = addMonths(focusedDate, e.shiftKey ? -12 : -1);
break;
case "PageDown":
d = addMonths(focusedDate, e.shiftKey ? 12 : 1);
break;
default:
return;
}
e.preventDefault();
if (d) moveFocus(d);
};
const focusedIdx = cells.findIndex((c) => sameDay(c, focusedDate));
const tabbableIdx =
focusedIdx >= 0 ? focusedIdx : new Date(view.y, view.m, 1).getDay();
const monthLabel = `${MONTHS[view.m]} ${view.y}`;
return (
<div ref={rootRef} className={`relative ${className}`}>
<label
htmlFor={inputId}
className="mb-1.5 block text-[13px] font-medium text-foreground"
>
{label}
</label>
<div
className={`flex items-center gap-1.5 rounded-sm border bg-background pl-3 pr-1.5 transition-colors ${
invalid ? "border-foreground/40" : "border-border"
} ${
disabled
? "cursor-not-allowed opacity-60"
: "focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/25 hover:border-foreground/25"
}`}
>
<input
ref={inputRef}
id={inputId}
type="text"
inputMode="numeric"
autoComplete="off"
placeholder="MM/DD/YYYY"
disabled={disabled}
value={text}
aria-invalid={invalid || undefined}
aria-describedby={helperId}
onChange={(e) => {
const raw = e.target.value;
const caret = e.target.selectionStart ?? raw.length;
// caret position expressed as "how many digits precede it" is
// stable across reformatting; slash count shifts around it
const digitsBeforeCaret = raw
.slice(0, caret)
.replace(/\D/g, "").length;
const next = maskText(raw);
let pos = next.length;
if (digitsBeforeCaret <= 0) {
pos = 0;
} else {
let seen = 0;
for (let i = 0; i < next.length; i++) {
if (/\d/.test(next[i])) {
seen++;
if (seen === digitsBeforeCaret) {
pos = i + 1;
break;
}
}
}
}
pendingCaretRef.current = pos;
setText(next);
const d = parseMasked(next);
if (d) {
if (!isControlled) setInternal(d);
onValueChange?.(d);
setFocusedDate(d);
setView({ y: d.getFullYear(), m: d.getMonth() });
}
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
openPopover();
} else if (e.key === "Escape" && open) {
e.preventDefault();
close(true);
} else if (e.key === "Enter" && open) {
e.preventDefault();
close(false);
}
}}
onBlur={(e) => {
if (rootRef.current?.contains(e.relatedTarget as Node)) return;
if (text !== "" && parseMasked(text) === null)
setText(selected ? fmt(selected) : "");
}}
className="h-9 w-full bg-transparent font-mono text-sm text-foreground placeholder:text-muted/60 focus:outline-none disabled:cursor-not-allowed"
/>
<button
type="button"
disabled={disabled}
aria-label={`${open ? "Close" : "Open"} calendar. ${previewName}.`}
aria-expanded={open}
aria-haspopup="grid"
aria-controls={open ? popId : undefined}
onClick={() => (open ? close(false) : openPopover())}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted transition-colors hover:bg-foreground/[0.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed"
>
<canvas
ref={iconCanvasRef}
width={ICON}
height={ICON}
aria-hidden
className="pointer-events-none"
/>
</button>
</div>
<p id={helperId} className="mt-1.5 font-mono text-xs text-muted">
{invalid
? "Not a valid date"
: `${previewName} · ${Math.round(illumination(previewF) * 100)}% illuminated`}
</p>
{open && (
<div
ref={popRef}
id={popId}
role="dialog"
aria-label="Choose date"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
close(true);
}
}}
className="absolute left-0 top-full z-30 mt-2 w-[292px] rounded-md border border-border bg-surface p-3 shadow-[0_12px_32px_-8px_rgba(0,0,0,0.35)]"
>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
aria-label="Previous month"
onClick={() => moveFocus(addMonths(focusedDate, -1))}
className="flex h-7 w-7 items-center justify-center rounded-sm text-muted transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<p
aria-live="polite"
className="text-[13px] font-semibold text-foreground"
>
{monthLabel}
</p>
<button
type="button"
aria-label="Next month"
onClick={() => moveFocus(addMonths(focusedDate, 1))}
className="flex h-7 w-7 items-center justify-center rounded-sm text-muted transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
<div ref={gridWrapRef} className="relative">
{/* one canvas behind the whole grid — all 42 moons + transit */}
<canvas
ref={gridCanvasRef}
aria-hidden
className="pointer-events-none absolute left-0 top-0"
/>
<div
role="grid"
aria-label={monthLabel}
onKeyDown={onGridKeyDown}
className="relative z-[1]"
>
<div role="row" className="grid grid-cols-7">
{WEEKDAYS.map(([abbr, full]) => (
<div
key={abbr}
role="columnheader"
aria-label={full}
className="pb-1 text-center font-mono text-[10px] uppercase tracking-wider text-muted"
>
{abbr}
</div>
))}
</div>
{Array.from({ length: 6 }, (_, r) => (
<div key={r} role="row" className="grid grid-cols-7">
{cells.slice(r * 7, r * 7 + 7).map((date, c) => {
const i = r * 7 + c;
const inMonth = date.getMonth() === view.m;
const isSel = selected !== null && sameDay(date, selected);
const isToday = sameDay(date, today);
const f = phaseFraction(date);
return (
<button
key={i}
ref={(el) => {
cellRefs.current[i] = el;
}}
type="button"
role="gridcell"
tabIndex={i === tabbableIdx ? 0 : -1}
aria-selected={isSel}
aria-current={isToday ? "date" : undefined}
aria-label={`${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}. ${PHASE_NAMES[phaseIndex(f)]}.`}
onClick={() => commit(date, i)}
onFocus={() => setFocusedDate(date)}
className={`relative flex h-11 flex-col items-center rounded-sm pt-[7px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${
isSel
? "bg-accent/10 text-accent"
: inMonth
? "text-foreground hover:bg-foreground/[0.06]"
: "text-muted/70 hover:bg-foreground/[0.04]"
}`}
>
<span
className={`text-[13px] leading-none ${
isToday && !isSel
? "underline decoration-muted underline-offset-[3px]"
: ""
}`}
>
{date.getDate()}
</span>
</button>
);
})}
</div>
))}
</div>
</div>
</div>
)}
</div>
);
}
Build a date input whose popover calendar doubles as a moon-phase almanac. RENDER: masked text input (MM/DD/YYYY, digits auto-slashed, full validation with revert-on-blur) plus a popover role=grid month calendar of 42 real button cells (role=gridcell, roving tabindex); ONE canvas absolutely positioned behind the whole grid draws all 42 moon glyphs (14px discs) at cell centers measured via getBoundingClientRect after a double-rAF layout settle, plus a 20px canvas in the input's trailing icon slot; both canvases get EXPLICIT style.width/height and dpr-clamped backing stores (a canvas is a replaced element — inset never sizes it). PHASE MATH: mean synodic month 29.530588853 d, reference new moon epoch 2000-01-06 18:14 UTC, sampled at local noon; f = ((days since epoch) mod syn)/syn; illuminated fraction k = (1 - cos(2*pi*f))/2; disc stroked --border, lit region --foreground at 85% alpha, terminator drawn as a semi-ellipse with x-radius r*|cos(2*pi*f)|, lit limb right while waxing (f < 0.5) and left while waning, ellipse sweep direction flipping crescent vs gibbous. MOTION: trailing icon crossfades 150ms (dual-alpha draw, cleared per frame) whenever focus movement or typing changes the previewed date; committing from the grid fires an eclipse transit — a foreground corona behind the chosen numeral occluded by an opaque background-ink shadow disc sweeping left-to-right over 420ms ease-out-expo, clipped to the cell, with the entire grid canvas cleared and all 42 moons redrawn every frame (zero accumulation), then the popover closes and focus returns to the input; month changes re-measure and re-lay glyphs after cell layout settles (double rAF + ResizeObserver). rAF runs only during crossfade/transit and is parked offscreen (IntersectionObserver) and on document.hidden — the static almanac costs zero frames. INTERACTION: Arrows move a day/week, Home/End week edges, PageUp/Down month, Shift+PageUp/Down year (view follows focus), Enter/click commits, Esc or outside pointerdown closes (Esc returns focus), ArrowDown in the input opens; aria-selected and aria-current=date on cells; phase name (e.g. Waxing gibbous) in every cell's accessible label and as visible helper text with percent illumination under the field. REDUCED MOTION: no transit or crossfade, instant selection and close; static moons stay as information. TOKENS: all canvas ink parsed from getComputedStyle(--foreground/--border/--background) at mount and re-derived via MutationObserver on documentElement class; hover/focus affordances token-relative (foreground-alpha tints, accent focus rings). Guard zero-size grids before measuring, and tear down every rAF, timer, observer, and document listener on unmount.