Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/propel/src/components/calendar/calendar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,42 @@ export const WithDisabledDays: Story = {
},
};

/**
* The current date carries a 6px accent dot, centered 4px above the bottom edge of its 40px cell.
* The dot persists when today is selected (inside the solid pill) or mid-range. `today` is pinned
* to Jan 6 so the story renders deterministically.
*/
export const TodayIndicator: Story = {
render: function Render() {
const [selected, setSelected] = React.useState<Date | undefined>();
return (
<Calendar
mode="single"
defaultMonth={JANUARY_2025}
today={new Date(2025, 0, 6)}
selected={selected}
onSelect={setSelected}
/>
);
},
play: async ({ canvas, userEvent }) => {
const todayButton = canvas.getByRole("button", { name: /^Today, .*January 6th, 2025/ });
const todayCell = todayButton.closest("td") as HTMLTableCellElement;
const dot = getComputedStyle(todayCell, "::after");
await expect(dot.width).toBe("6px");
await expect(dot.height).toBe("6px");
await expect(dot.bottom).toBe("4px");

// The dot survives selection: select today, the pseudo-element is still there.
await userEvent.click(todayButton);
await expect(canvas.getByRole("gridcell", { selected: true })).toHaveAttribute(
"data-day",
"2025-01-06",
);
await expect(getComputedStyle(todayCell, "::after").width).toBe("6px");
},
};

/**
* Behavior tests: the month grid renders day buttons, clicking a day selects it, and a disabled day
* stays unselectable. Tagged out of the sidebar/docs/manifest but still runs under `test`. A fixed
Expand Down
40 changes: 28 additions & 12 deletions packages/propel/src/components/calendar/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,36 @@ import { DayPicker, type DayPickerProps } from "react-day-picker";
import { calendarClassNames } from "../../elements/calendar";

/**
* Props for {@link Calendar}. Forwards every react-day-picker `DayPickerProps` (`mode`, `selected`,
* `onSelect`, `disabled`, `month`, `defaultMonth`, …) but drops `className`/`style`/`classNames` —
* styling is owned by propel tokens (the `elements/calendar` `calendarClassNames` contract, grafted
* below).
* Props for {@link Calendar}. Forwards react-day-picker's `DayPickerProps` (`mode`, `selected`,
* `onSelect`, `disabled`, `month`, `defaultMonth`, …). `className`/`style`/`classNames` are dropped
* because styling is owned by propel tokens (the `elements/calendar` `calendarClassNames` contract,
* grafted below). `numberOfMonths`/`captionLayout`/`showWeekNumber` are dropped because
* `calendarClassNames` does not style those layout modes — they would render with
* react-day-picker's unstyled default classes (we never load its stylesheet), so we omit them
* rather than accept a prop that silently breaks the layout. Add the `classNames` keys + stories
* first if a mode is needed.
*/
// `DayPickerProps` is a discriminated union over `mode`; a plain `Omit` collapses
// it and de-correlates `selected`/`onSelect`. Omit distributively so each union
// member (and its mode↔selected↔onSelect relationship) is preserved.
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
export type CalendarProps = DistributiveOmit<DayPickerProps, "className" | "style" | "classNames">;
export type CalendarProps = DistributiveOmit<
DayPickerProps,
"className" | "style" | "classNames" | "numberOfMonths" | "captionLayout" | "showWeekNumber"
>;

// Hoisted so its identity is stable across renders — react-day-picker keys its component registry
// by identity, so an inline definition would remount the chevron subtree on every render. It
// captures nothing, so module scope is safe.
const renderChevron: NonNullable<DayPickerProps["components"]>["Chevron"] = ({
orientation,
className,
}) =>
orientation === "left" ? (
<ChevronLeft aria-hidden className={className} />
) : (
<ChevronRight aria-hidden className={className} />
);

/**
* The ready-made date picker: react-day-picker's `DayPicker` behavior grafted onto propel's
Expand All @@ -22,20 +42,16 @@ export type CalendarProps = DistributiveOmit<DayPickerProps, "className" | "styl
* screen-reader-accessible (react-day-picker owns the grid semantics). Pass `selected`/`onSelect`
* to control the selection. The chevron icons carry no className — react-day-picker passes
* `classNames.chevron` (16px, RTL-mirrored) through to them; a caller's own `components.Chevron`
* still wins.
* still wins. Weeks start Monday (`weekStartsOn={1}`, per spec); pass `weekStartsOn` to override.
*/
export function Calendar(props: CalendarProps) {
return (
<DayPicker
weekStartsOn={1}
classNames={calendarClassNames}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The supplied spec requires a Monday-first week order, but DayPicker defaults to Sunday and the new stories render Su first. Could we set weekStartsOn={1} here, while still allowing a deliberate consumer override?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

{...props}
components={{
Chevron: ({ orientation, className }) =>
orientation === "left" ? (
<ChevronLeft aria-hidden className={className} />
) : (
<ChevronRight aria-hidden className={className} />
),
Chevron: renderChevron,
...props.components,
}}
/>
Expand Down
22 changes: 19 additions & 3 deletions packages/propel/src/elements/calendar/calendar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export const States: Story = {
<nav className={calendarClassNames.nav}>
<button
type="button"
disabled
aria-disabled
aria-label="Go to the Previous Month"
className={calendarClassNames.button_previous}
>
Expand Down Expand Up @@ -477,13 +477,24 @@ export const StatesCanary: Story = {
getComputedStyle(resting).backgroundColor,
);

// `today` recolors the (unselected) day's text to the accent tone.
// `today` recolors the (unselected) day's text to the accent tone and paints the 6px
// current-date dot 4px above the CELL's bottom edge — including on a selected today.
const today = canvas.getByRole("button", { name: "Day 3, today" });
await expect(getComputedStyle(today).color).not.toBe(getComputedStyle(resting).color);
const todayDot = getComputedStyle(cellOf(today), "::after");
await expect(todayDot.width).toBe("6px");
await expect(todayDot.height).toBe("6px");
await expect(todayDot.bottom).toBe("4px");
const selectedToday = canvas.getByRole("button", { name: "Day 5, selected today" });
await expect(getComputedStyle(cellOf(selectedToday), "::after").width).toBe("6px");

// `disabled` makes the day button non-interactive.
// `disabled` makes the day button non-interactive and visibly recessed — the disabled text
// tone alone (full opacity, matching every other disabled control), so it reads muted against
// an enabled day rather than lighter than Figma.
const disabled = canvas.getByRole("button", { name: "Day 6, disabled" });
await expect(getComputedStyle(disabled).pointerEvents).toBe("none");
await expect(getComputedStyle(disabled).opacity).toBe("1");
await expect(getComputedStyle(disabled).color).not.toBe(getComputedStyle(resting).color);

// `range-middle` paints the soft in-range fill on the CELL, and its button reset beats
// `selected`'s accent fill (the cell carries both classes) so the button stays transparent.
Expand All @@ -497,5 +508,10 @@ export const StatesCanary: Story = {
const hidden = canvasElement.querySelector("#elements-calendar-hidden-day");
if (!(hidden instanceof HTMLElement)) throw new Error("missing pinned hidden cell");
await expect(getComputedStyle(hidden).visibility).toBe("hidden");

// nav `aria-disabled` (the attribute react-day-picker sets, not native `disabled`) dims the
// chevron button to 60% opacity (the system disabled convention).
const navDisabled = canvas.getByRole("button", { name: "Go to the Previous Month" });
await expect(getComputedStyle(navDisabled).opacity).toBe("0.6");
},
};
23 changes: 15 additions & 8 deletions packages/propel/src/elements/calendar/variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import type { DayPickerProps } from "react-day-picker";

import { surfaceVariants } from "../../internal/surface";

// The two nav buttons are identical 28px transparent icon buttons that tint on hover (Figma "Icon
// buttons"). react-day-picker sets `aria-disabled` (not native `disabled`) at the month bounds, so
// the disabled dim keys off that; `opacity-60` matches the system-wide disabled convention.
const navButton =
"inline-flex size-7 items-center justify-center rounded-md text-icon-tertiary hover:bg-layer-transparent-hover aria-disabled:opacity-60";

// Propel-token class map for react-day-picker's UI parts. We never import its
// default stylesheet — every visual decision below comes from a propel utility
// (Figma node 1272-11630): 40px round day cells, accent endpoints, soft in-range
Expand All @@ -18,11 +24,8 @@ export const calendarClassNames: Partial<NonNullable<DayPickerProps["classNames"
month_caption: "flex items-center h-7",
caption_label: "text-20 font-semibold text-primary",
nav: "absolute inset-e-4 top-4 flex items-center gap-1",
// 28px transparent icon buttons that tint on hover (Figma "Icon buttons").
button_previous:
"inline-flex size-7 items-center justify-center rounded-md text-icon-tertiary hover:bg-layer-transparent-hover aria-disabled:opacity-50",
button_next:
"inline-flex size-7 items-center justify-center rounded-md text-icon-tertiary hover:bg-layer-transparent-hover aria-disabled:opacity-50",
button_previous: navButton,
button_next: navButton,
// The nav chevron: 16px, mirrored in RTL. react-day-picker passes this class to its `Chevron`
// component (default or a custom one), so the icon needs no className of its own.
chevron: "size-4 rtl:-scale-x-100",
Expand Down Expand Up @@ -60,9 +63,13 @@ export const calendarClassNames: Partial<NonNullable<DayPickerProps["classNames"
range_middle:
"range-middle [&>button]:bg-transparent [&>button]:text-primary [&>button:hover]:bg-transparent",
// Today (when not selected): accent text so the current date stands out. When
// the cell is also selected, the solid accent button (above) wins.
today: "[&:not([aria-selected])>button]:text-accent-primary [&>button]:font-semibold",
// Disabled days read as muted, non-interactive text.
// the cell is also selected, the solid accent button (above) wins. A 6px dot
// (icon/accent/subtle) sits centered 4px above the cell's bottom edge in
// every state, marking the current date even inside a selected pill.
today:
"[&:not([aria-selected])>button]:text-accent-primary [&>button]:font-semibold after:absolute after:bottom-1 after:left-1/2 after:-translate-x-1/2 after:size-1.5 after:rounded-full after:bg-(--txt-icon-accent-subtle)",
// Disabled days read as muted, non-interactive text via the disabled tone alone (matching every
// other disabled control — no extra opacity), so they recede without going lighter than Figma.
disabled: "[&>button]:text-disabled [&>button]:pointer-events-none",
// Days from the adjacent month: dimmed.
outside: "[&>button]:text-tertiary",
Expand Down