feat(date-picker): support configurable week starts
This commit is contained in:
@@ -37,7 +37,7 @@ function DatePickerPlayground() {
|
||||
|
||||
function DatePickerScenarios() {
|
||||
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>
|
||||
<CardHeader>
|
||||
<CardTitle>Empty state</CardTitle>
|
||||
@@ -48,6 +48,24 @@ function DatePickerScenarios() {
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle>Guardrailed window</CardTitle>
|
||||
@@ -76,7 +94,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user