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
@@ -86,6 +86,39 @@ describe("DatePicker", () => {
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 () => {
render(
<DatePicker
+164 -149
View File
@@ -44,6 +44,7 @@ import { createDataAttributes, createSlot } from "../lib/contracts";
import { ChevronDownIcon, ChevronRightIcon } from "../lib/icons";
type DatePickerValue = Date | undefined;
export type DatePickerWeekStartsOn = "monday" | "sunday";
function startOfMonth(value: Date) {
return new Date(value.getFullYear(), value.getMonth(), 1);
@@ -102,9 +103,14 @@ function formatMonthLabel(value: Date, locale?: string) {
}).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 startOffset = firstDay.getDay();
const weekStartIndex = getWeekStartIndex(weekStartsOn);
const startOffset = (firstDay.getDay() - weekStartIndex + 7) % 7;
const gridStart = new Date(firstDay);
gridStart.setDate(firstDay.getDate() - startOffset);
@@ -198,6 +204,7 @@ export type DatePickerProps = Omit<
placeholder?: string;
todayLabel?: ReactNode;
value?: Date;
weekStartsOn?: DatePickerWeekStartsOn;
};
export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function DatePicker(
@@ -220,6 +227,7 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
placeholder = "Select date",
todayLabel = "Today",
value,
weekStartsOn = "monday",
...props
},
ref
@@ -260,15 +268,18 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
const monthLabel = formatMonthLabel(visibleMonth, locale);
const weekdays = useMemo(() => {
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) => {
const day = new Date(base);
day.setDate(base.getDate() + index);
return formatter.format(day);
});
}, [locale]);
const days = useMemo(() => buildMonthGrid(visibleMonth), [visibleMonth]);
}, [locale, weekStartsOn]);
const days = useMemo(
() => buildMonthGrid(visibleMonth, weekStartsOn),
[visibleMonth, weekStartsOn]
);
const yearOptions = useMemo(
() => getYearOptions(visibleMonth, selectedDate),
[selectedDate, visibleMonth]
@@ -420,161 +431,165 @@ export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function
</PopoverAnchor>
<PopoverContent className={datePickerContentVariants()} padding="sm" size="xl">
<div {...createSlot("header")} className={datePickerHeaderVariants()}>
<div className={datePickerNavigationVariants()}>
<Button
aria-label="Previous month"
size="icon"
variant="ghost"
onClick={() => {
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}
<div className="grid gap-3">
<div {...createSlot("header")} className={datePickerHeaderVariants()}>
<div className={datePickerNavigationVariants()}>
<Button
aria-label="Previous month"
size="icon"
variant="ghost"
onClick={() => {
setSelectedDate(normalizeDate(day));
setOpenState(false);
goToMonth(-1);
}}
onKeyDown={(event) => handleDayKeyDown(event, index)}
ref={(node) => {
dayRefs.current[index] = node;
}}
role="gridcell"
type="button"
>
{day.getDate()}
</button>
);
})}
</div>
<ChevronRightIcon className="size-3.5 rotate-180" />
</Button>
</div>
<div className={datePickerFooterVariants()}>
<Button
size="sm"
variant="subtle"
onClick={() => {
setSelectedDate(today);
if (today) {
setVisibleMonth(startOfMonth(today));
}
setOpenState(false);
}}
>
{todayLabel}
</Button>
<div className="flex items-center gap-2">
<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>
<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
size="sm"
variant="ghost"
onClick={() => {
setSelectedDate(undefined);
}}
>
{clearLabel}
</Button>
<Button
size="sm"
variant="secondary"
variant="subtle"
onClick={() => {
setSelectedDate(today);
if (today) {
setVisibleMonth(startOfMonth(today));
}
setOpenState(false);
}}
>
Done
{todayLabel}
</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>
</PopoverContent>
@@ -8,13 +8,13 @@ export const datePickerFieldVariants = cva("relative");
export const datePickerTriggerVariants = cva("w-full");
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)]",
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
]);
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");
@@ -26,7 +26,7 @@ export const datePickerMonthLabelVariants = 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(
@@ -50,5 +50,5 @@ export const datePickerDayVariants = 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,
dataTableToolbarVariants
} from "./components/data-table.variants";
export { DatePicker, type DatePickerProps } from "./components/date-picker";
export {
DatePicker,
type DatePickerProps,
type DatePickerWeekStartsOn
} from "./components/date-picker";
export {
datePickerContentVariants,
datePickerDayVariants,