feat(date-picker): support configurable week starts

This commit is contained in:
2026-03-23 11:28:53 +08:00
parent 4d67f4ad76
commit c570431dba
5 changed files with 226 additions and 156 deletions
@@ -37,7 +37,7 @@ function DatePickerPlayground() {
function DatePickerScenarios() { function DatePickerScenarios() {
return ( return (
<div className="grid w-full max-w-4xl gap-4 md:grid-cols-2"> <div className="grid w-full max-w-6xl gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Empty state</CardTitle> <CardTitle>Empty state</CardTitle>
@@ -48,6 +48,24 @@ function DatePickerScenarios() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Sunday-first calendar</CardTitle>
<CardDescription>
Opt into Sunday-through-Saturday when matching US-style calendar conventions.
</CardDescription>
</CardHeader>
<CardContent>
<DatePicker
aria-label="Sunday-first launch date"
defaultMonth={new Date(2026, 2, 1)}
defaultValue={new Date(2026, 2, 18)}
locale="en-US"
weekStartsOn="sunday"
/>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Guardrailed window</CardTitle> <CardTitle>Guardrailed window</CardTitle>
@@ -76,7 +94,7 @@ const meta = {
docs: { docs: {
description: { description: {
component: component:
"A single-date picker for launch windows, review deadlines, and operator scheduling surfaces. This first slice stays intentionally narrow: one date, one popover calendar, no range or timezone API." "A single-date picker for launch windows, review deadlines, and operator scheduling surfaces. It defaults to a Monday-through-Sunday calendar, with an opt-in Sunday-first mode for teams that need that convention. This first slice stays intentionally narrow: one date, one popover calendar, no range or timezone API."
} }
}, },
layout: "centered" layout: "centered"
@@ -86,6 +86,39 @@ describe("DatePicker", () => {
expect(screen.getByText("April 2028")).toBeInTheDocument(); expect(screen.getByText("April 2028")).toBeInTheDocument();
}); });
it("defaults to a monday-first calendar grid", () => {
render(
<DatePicker
aria-label="Monday-first date"
defaultMonth={new Date(2026, 2, 1)}
defaultOpen
locale="en-US"
/>
);
const firstDay = within(screen.getByRole("grid")).getAllByRole("gridcell")[0];
expect(firstDay).toHaveAccessibleName("Feb 23, 2026");
expect(screen.getByText("Mon")).toBeInTheDocument();
});
it("supports sunday-first calendar grids when requested", () => {
render(
<DatePicker
aria-label="Sunday-first date"
defaultMonth={new Date(2026, 2, 1)}
defaultOpen
locale="en-US"
weekStartsOn="sunday"
/>
);
const firstDay = within(screen.getByRole("grid")).getAllByRole("gridcell")[0];
expect(firstDay).toHaveAccessibleName("Mar 1, 2026");
expect(screen.getByText("Sun")).toBeInTheDocument();
});
it("respects min and max dates", async () => { it("respects min and max dates", async () => {
render( render(
<DatePicker <DatePicker
+164 -149
View File
@@ -44,6 +44,7 @@ import { createDataAttributes, createSlot } from "../lib/contracts";
import { ChevronDownIcon, ChevronRightIcon } from "../lib/icons"; import { ChevronDownIcon, ChevronRightIcon } from "../lib/icons";
type DatePickerValue = Date | undefined; type DatePickerValue = Date | undefined;
export type DatePickerWeekStartsOn = "monday" | "sunday";
function startOfMonth(value: Date) { function startOfMonth(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), 1); return new Date(value.getFullYear(), value.getMonth(), 1);
@@ -102,9 +103,14 @@ function formatMonthLabel(value: Date, locale?: string) {
}).format(value); }).format(value);
} }
function buildMonthGrid(month: Date) { function getWeekStartIndex(weekStartsOn: DatePickerWeekStartsOn) {
return weekStartsOn === "monday" ? 1 : 0;
}
function buildMonthGrid(month: Date, weekStartsOn: DatePickerWeekStartsOn) {
const firstDay = startOfMonth(month); const firstDay = startOfMonth(month);
const startOffset = firstDay.getDay(); const weekStartIndex = getWeekStartIndex(weekStartsOn);
const startOffset = (firstDay.getDay() - weekStartIndex + 7) % 7;
const gridStart = new Date(firstDay); const gridStart = new Date(firstDay);
gridStart.setDate(firstDay.getDate() - startOffset); gridStart.setDate(firstDay.getDate() - startOffset);
@@ -198,6 +204,7 @@ export type DatePickerProps = Omit<
placeholder?: string; placeholder?: string;
todayLabel?: ReactNode; todayLabel?: ReactNode;
value?: Date; value?: Date;
weekStartsOn?: DatePickerWeekStartsOn;
}; };
export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function DatePicker( export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function DatePicker(
@@ -220,6 +227,7 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
placeholder = "Select date", placeholder = "Select date",
todayLabel = "Today", todayLabel = "Today",
value, value,
weekStartsOn = "monday",
...props ...props
}, },
ref ref
@@ -260,15 +268,18 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
const monthLabel = formatMonthLabel(visibleMonth, locale); const monthLabel = formatMonthLabel(visibleMonth, locale);
const weekdays = useMemo(() => { const weekdays = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" }); const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
const base = new Date(2025, 0, 5); const base = new Date(2025, 0, 5 + getWeekStartIndex(weekStartsOn));
return Array.from({ length: 7 }, (_, index) => { return Array.from({ length: 7 }, (_, index) => {
const day = new Date(base); const day = new Date(base);
day.setDate(base.getDate() + index); day.setDate(base.getDate() + index);
return formatter.format(day); return formatter.format(day);
}); });
}, [locale]); }, [locale, weekStartsOn]);
const days = useMemo(() => buildMonthGrid(visibleMonth), [visibleMonth]); const days = useMemo(
() => buildMonthGrid(visibleMonth, weekStartsOn),
[visibleMonth, weekStartsOn]
);
const yearOptions = useMemo( const yearOptions = useMemo(
() => getYearOptions(visibleMonth, selectedDate), () => getYearOptions(visibleMonth, selectedDate),
[selectedDate, visibleMonth] [selectedDate, visibleMonth]
@@ -420,161 +431,165 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
</PopoverAnchor> </PopoverAnchor>
<PopoverContent className={datePickerContentVariants()} padding="sm" size="xl"> <PopoverContent className={datePickerContentVariants()} padding="sm" size="xl">
<div {...createSlot("header")} className={datePickerHeaderVariants()}> <div className="grid gap-3">
<div className={datePickerNavigationVariants()}> <div {...createSlot("header")} className={datePickerHeaderVariants()}>
<Button <div className={datePickerNavigationVariants()}>
aria-label="Previous month" <Button
size="icon" aria-label="Previous month"
variant="ghost" size="icon"
onClick={() => { variant="ghost"
goToMonth(-1);
}}
>
<ChevronRightIcon className="size-3.5 rotate-180" />
</Button>
</div>
<div className={datePickerSelectorsVariants()}>
<Select
value={String(visibleMonth.getMonth())}
onValueChange={(nextValue) => {
const nextMonth = new Date(visibleMonth.getFullYear(), Number(nextValue), 1);
setVisibleMonth(nextMonth);
onMonthChange?.(nextMonth);
}}
>
<SelectTrigger aria-label="Month" className="w-full">
<SelectValue placeholder={monthLabel} />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 12 }, (_, monthIndex) => {
const monthName = new Intl.DateTimeFormat(locale, {
month: "long"
}).format(new Date(visibleMonth.getFullYear(), monthIndex, 1));
return (
<SelectItem key={monthIndex} value={String(monthIndex)}>
{monthName}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Select
value={String(visibleMonth.getFullYear())}
onValueChange={(nextValue) => {
const nextMonth = new Date(Number(nextValue), visibleMonth.getMonth(), 1);
setVisibleMonth(nextMonth);
onMonthChange?.(nextMonth);
}}
>
<SelectTrigger aria-label="Year" className="w-full">
<SelectValue placeholder={String(visibleMonth.getFullYear())} />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={String(year)}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={datePickerNavigationVariants()}>
<Button
aria-label="Next month"
size="icon"
variant="ghost"
onClick={() => {
goToMonth(1);
}}
>
<ChevronRightIcon className="size-3.5" />
</Button>
</div>
</div>
<p className={datePickerCaptionVariants()}>{monthLabel}</p>
<div className={datePickerWeekdayVariants()}>
{weekdays.map((weekday) => (
<span key={weekday}>{weekday}</span>
))}
</div>
<div className={datePickerGridVariants()} role="grid">
{days.map((day, index) => {
const outside = day.getMonth() !== visibleMonth.getMonth();
const selected = sameDay(day, selectedDate);
const isToday = sameDay(day, today);
const dayDisabled = isDateDisabled(day, minDate, maxDate);
return (
<button
key={day.toISOString()}
{...createSlot("day")}
{...createDataAttributes({
disabled: dayDisabled,
outside,
selected,
today: isToday
})}
aria-label={formatValue(day, locale)}
aria-pressed={selected}
className={datePickerDayVariants()}
disabled={dayDisabled}
onClick={() => { onClick={() => {
setSelectedDate(normalizeDate(day)); goToMonth(-1);
setOpenState(false);
}} }}
onKeyDown={(event) => handleDayKeyDown(event, index)}
ref={(node) => {
dayRefs.current[index] = node;
}}
role="gridcell"
type="button"
> >
{day.getDate()} <ChevronRightIcon className="size-3.5 rotate-180" />
</button> </Button>
); </div>
})}
</div>
<div className={datePickerFooterVariants()}> <div className={datePickerSelectorsVariants()}>
<Button <Select
size="sm" value={String(visibleMonth.getMonth())}
variant="subtle" onValueChange={(nextValue) => {
onClick={() => { const nextMonth = new Date(visibleMonth.getFullYear(), Number(nextValue), 1);
setSelectedDate(today); setVisibleMonth(nextMonth);
if (today) { onMonthChange?.(nextMonth);
setVisibleMonth(startOfMonth(today)); }}
} >
setOpenState(false); <SelectTrigger aria-label="Month" className="w-full">
}} <SelectValue placeholder={monthLabel} />
> </SelectTrigger>
{todayLabel} <SelectContent>
</Button> {Array.from({ length: 12 }, (_, monthIndex) => {
<div className="flex items-center gap-2"> const monthName = new Intl.DateTimeFormat(locale, {
month: "long"
}).format(new Date(visibleMonth.getFullYear(), monthIndex, 1));
return (
<SelectItem key={monthIndex} value={String(monthIndex)}>
{monthName}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Select
value={String(visibleMonth.getFullYear())}
onValueChange={(nextValue) => {
const nextMonth = new Date(Number(nextValue), visibleMonth.getMonth(), 1);
setVisibleMonth(nextMonth);
onMonthChange?.(nextMonth);
}}
>
<SelectTrigger aria-label="Year" className="w-full">
<SelectValue placeholder={String(visibleMonth.getFullYear())} />
</SelectTrigger>
<SelectContent>
{yearOptions.map((year) => (
<SelectItem key={year} value={String(year)}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className={datePickerNavigationVariants()}>
<Button
aria-label="Next month"
size="icon"
variant="ghost"
onClick={() => {
goToMonth(1);
}}
>
<ChevronRightIcon className="size-3.5" />
</Button>
</div>
</div>
<div className="grid gap-2">
<p className={datePickerCaptionVariants()}>{monthLabel}</p>
<div className={datePickerWeekdayVariants()}>
{weekdays.map((weekday) => (
<span key={weekday}>{weekday}</span>
))}
</div>
<div className={datePickerGridVariants()} role="grid">
{days.map((day, index) => {
const outside = day.getMonth() !== visibleMonth.getMonth();
const selected = sameDay(day, selectedDate);
const isToday = sameDay(day, today);
const dayDisabled = isDateDisabled(day, minDate, maxDate);
return (
<button
key={day.toISOString()}
{...createSlot("day")}
{...createDataAttributes({
disabled: dayDisabled,
outside,
selected,
today: isToday
})}
aria-label={formatValue(day, locale)}
aria-pressed={selected}
className={datePickerDayVariants()}
disabled={dayDisabled}
onClick={() => {
setSelectedDate(normalizeDate(day));
setOpenState(false);
}}
onKeyDown={(event) => handleDayKeyDown(event, index)}
ref={(node) => {
dayRefs.current[index] = node;
}}
role="gridcell"
type="button"
>
{day.getDate()}
</button>
);
})}
</div>
</div>
<div className={datePickerFooterVariants()}>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="subtle"
onClick={() => {
setSelectedDate(undefined);
}}
>
{clearLabel}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => { onClick={() => {
setSelectedDate(today);
if (today) {
setVisibleMonth(startOfMonth(today));
}
setOpenState(false); setOpenState(false);
}} }}
> >
Done {todayLabel}
</Button> </Button>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedDate(undefined);
}}
>
{clearLabel}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
setOpenState(false);
}}
>
Done
</Button>
</div>
</div> </div>
</div> </div>
</PopoverContent> </PopoverContent>
@@ -8,13 +8,13 @@ export const datePickerFieldVariants = cva("relative");
export const datePickerTriggerVariants = cva("w-full"); export const datePickerTriggerVariants = cva("w-full");
export const datePickerContentVariants = cva([ export const datePickerContentVariants = cva([
"relative z-50 w-[21rem] overflow-hidden rounded-[var(--ui-panel-radius)] border border-[var(--ui-panel-border)] bg-[var(--ui-panel-bg)] p-0 text-[var(--color-card-foreground)] shadow-[var(--ui-panel-shadow)] outline-none", "relative z-50 w-[22rem] overflow-hidden rounded-[var(--ui-panel-radius)] border border-[var(--ui-panel-border)] bg-[var(--ui-panel-bg)] text-[var(--color-card-foreground)] shadow-[var(--ui-panel-shadow)] outline-none",
"[border-width:var(--ui-panel-border-width)] backdrop-blur-[var(--ui-panel-backdrop-blur)]", "[border-width:var(--ui-panel-border-width)] backdrop-blur-[var(--ui-panel-backdrop-blur)]",
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop" "data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
]); ]);
export const datePickerHeaderVariants = cva( export const datePickerHeaderVariants = cva(
"grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)_auto]" "grid items-center gap-3 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
); );
export const datePickerNavigationVariants = cva("flex items-center gap-2"); export const datePickerNavigationVariants = cva("flex items-center gap-2");
@@ -26,7 +26,7 @@ export const datePickerMonthLabelVariants = cva(
); );
export const datePickerCaptionVariants = cva( export const datePickerCaptionVariants = cva(
"px-1 text-sm leading-6 text-[var(--color-muted-foreground)]" "text-sm leading-6 text-[var(--color-muted-foreground)]"
); );
export const datePickerWeekdayVariants = cva( export const datePickerWeekdayVariants = cva(
@@ -50,5 +50,5 @@ export const datePickerDayVariants = cva(
); );
export const datePickerFooterVariants = cva( export const datePickerFooterVariants = cva(
"flex flex-wrap items-center justify-between gap-3 border-t border-[var(--ui-panel-border)] pt-3" "mt-1 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--ui-panel-border)] pt-3"
); );
+5 -1
View File
@@ -148,7 +148,11 @@ export {
dataTableTableVariants, dataTableTableVariants,
dataTableToolbarVariants dataTableToolbarVariants
} from "./components/data-table.variants"; } from "./components/data-table.variants";
export { DatePicker, type DatePickerProps } from "./components/date-picker"; export {
DatePicker,
type DatePickerProps,
type DatePickerWeekStartsOn
} from "./components/date-picker";
export { export {
datePickerContentVariants, datePickerContentVariants,
datePickerDayVariants, datePickerDayVariants,