diff --git a/.changeset/aria-labels-press-themes.md b/.changeset/aria-labels-press-themes.md new file mode 100644 index 0000000..5a787c8 --- /dev/null +++ b/.changeset/aria-labels-press-themes.md @@ -0,0 +1,10 @@ +--- +"@dateforge/react-calendar": minor +--- +Add cascading `actionLabels` config to `` for centralized aria-label customization across all modules. + +Add `press` appearance — newspaper-style serif with sharp corners, wide letter-spacing, and flat shadows. + +Add `atelier` (light) and `bauhaus` (dark) themes — paired warm cream / cool ink palette with red dateline accent. + +Rename appearance tokens for clarity: `--header-padding` → `--cal-nav-padding`, `--header-min-height` → `--cal-nav-min-height`, `--cal-text-2xl` → `--cal-nav-font-size`, `--cal-text-xl` → `--cal-nav-meta-font-size`. The `headerPadding` / `headerMinHeight` TS keys become `navPadding` / `navMinHeight`, with new `navFontSize` / `navMetaFontSize` added. diff --git a/.size-limit.json b/.size-limit.json index 7714a9c..eee27f0 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -21,7 +21,7 @@ "./dist/modules/nav.mjs": "{ CalendarNav }", "./dist/modules/days.mjs": "{ CalendarDays }" }, - "limit": "24 KB", + "limit": "24.5 KB", "ignore": ["react", "react-dom"] }, { diff --git a/.storybook/helpers/use-frozen-time.ts b/.storybook/helpers/use-frozen-time.ts new file mode 100644 index 0000000..d264537 --- /dev/null +++ b/.storybook/helpers/use-frozen-time.ts @@ -0,0 +1,36 @@ +import { useLayoutEffect } from "react"; + +/** + * Freezes `new Date()` and `Date.now()` globally for the duration of the + * component's lifecycle. Used in stories that render live clocks (e.g. + * ``) so Chromatic snapshots are deterministic. + * + * Layout-effect timing matters: the override must be installed before the + * library reads the clock during render. Cleanup restores the real Date. + */ +export const useFrozenTime = (frozenAt: Date): void => { + useLayoutEffect(() => { + const RealDate = globalThis.Date; + const fixed = frozenAt.getTime(); + + class FrozenDate extends RealDate { + // biome-ignore lint/suspicious/noExplicitAny: variadic Date constructor + constructor(...args: any[]) { + if (args.length === 0) { + super(fixed); + } else { + // biome-ignore lint/suspicious/noExplicitAny: pass-through + super(...(args as [any])); + } + } + static now(): number { + return fixed; + } + } + + globalThis.Date = FrozenDate as DateConstructor; + return () => { + globalThis.Date = RealDate; + }; + }, [frozenAt]); +}; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 0e16c8c..650c807 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -52,15 +52,54 @@ const preview: Preview = { }, }, decorators: [ - (Story, ctx) => ( -
- -
- ), + (Story, ctx) => { + const viewportActive = Boolean( + (ctx.globals.viewport as { value?: string } | undefined)?.value, + ); + const style: React.CSSProperties = viewportActive + ? { padding: 8, width: "100%", boxSizing: "border-box" } + : { padding: 20, width: ctx.parameters.storyWidth ?? 305 }; + return ( +
+ +
+ ); + }, ], parameters: { + viewport: { + // Tuned to the calendar's container-query breakpoints + // (13.75em / 16.25em / 21.25em ≈ 220 / 260 / 340 px at 16px root). + options: { + narrow: { + name: "Narrow (220px)", + styles: { width: "220px", height: "640px" }, + type: "mobile", + }, + compact: { + name: "Compact (260px)", + styles: { width: "260px", height: "640px" }, + type: "mobile", + }, + medium: { + name: "Medium (340px)", + styles: { width: "340px", height: "720px" }, + type: "mobile", + }, + comfortable: { + name: "Comfortable (480px)", + styles: { width: "480px", height: "720px" }, + type: "tablet", + }, + full: { + name: "Full (800px)", + styles: { width: "800px", height: "900px" }, + type: "desktop", + }, + }, + }, controls: { disableSaveFromUI: true, matchers: { @@ -83,7 +122,11 @@ const preview: Preview = { ], }, }, - layout: "centered", + // Default Storybook layout is "padded". We avoid "centered" globally + // because it wraps `` in a flex container that collapses our + // viewport-driven `width: 100%` wrapper to 0px (storybook-root is itself + // a flex child with no intrinsic width). + layout: "padded", options: { storySort: { method: "alphabetical", diff --git a/DESIGN.md b/DESIGN.md index fad98d9..7aedb40 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -33,30 +33,30 @@ User layer wins over `themes` and `appearances`; both win over base/component/mo Source: `themes/themes.ts`. 15 tokens per theme. -| Token | Role | -| ------- | --------------------------------------------- | -| `--c-a` | accent — primary action | -| `--c-at`| activeText — text on active/pressed | +| Token | Role | +| --------- | ------------------------------------------- | +| `--c-a` | accent — primary action | +| `--c-at` | activeText — text on active/pressed | | `--c-t-d` | todayDot — dot under selected today | -| `--c-b` | backdrop — dialog/overlay background | -| `--c-h` | highlight — hover/focus indicator, selected | -| `--c-t` | tone — calendar grid background subtone | -| `--c-c` | text — primary text | -| `--c-s` | stroke — border, divider, outline | -| `--c-x` | shadow — shadow tint (alpha-blended) | -| `--c-d` | disabled — disabled control background | -| `--c-m` | mutedText — secondary/hint text | -| `--c-dt`| disabledText | -| `--c-we`| weekend — weekend cell highlight | -| `--c-r` | range — range selection background | -| `--c-e` | error — invalid state | +| `--c-b` | backdrop — dialog/overlay background | +| `--c-h` | highlight — hover/focus indicator, selected | +| `--c-t` | tone — calendar grid background subtone | +| `--c-c` | text — primary text | +| `--c-s` | stroke — border, divider, outline | +| `--c-x` | shadow — shadow tint (alpha-blended) | +| `--c-d` | disabled — disabled control background | +| `--c-m` | mutedText — secondary/hint text | +| `--c-dt` | disabledText | +| `--c-we` | weekend — weekend cell highlight | +| `--c-r` | range — range selection background | +| `--c-e` | error — invalid state | ### Typography tokens Source: `src/core/layout.module.css`. - `--cal-font-size`: `clamp(11px, 2.7cqw, 18px)` — container-relative base -- `--cal-text-2xs … --cal-text-2xl`: semantic scale (0.6em–1.1em) +- `--cal-text-2xs … --cal-text-lg`: semantic scale (0.6em–0.95em). `xl`/`2xl` were retired in favor of `--cal-nav-meta-font-size` / `--cal-nav-font-size` since they were nav-only. - `--cal-text-day`: `clamp(0.72em, …, 1.15em)` — adaptive day-cell sizing - `--cal-weight-{regular,medium,semibold,bold}`: 400–700 - `--cal-leading-{tight,normal,relaxed}`: 1 → 1.6 @@ -65,42 +65,47 @@ Source: `src/core/layout.module.css`. Source: `appearances/index.ts`. -| Token | Purpose | -| ----------------------- | ----------------------------------------------- | -| `--cal-radius` | base border-radius | -| `--cal-container-radius`| outer container radius (multiplier of radius) | -| `--cal-spacing` | base gap/padding unit | -| `--cal-border` | stroke width | -| `--cal-shadow-{sm,md,lg}`| shadow depth (uses `var(--c-x)`) | -| `--cal-transition` | animation duration | -| `--cal-days-padding` | day-cell padding | -| `--cal-track-height` | scrollable track height | -| `--cal-day-ratio` | day-cell aspect ratio | +| Token | Purpose | +| -------------------------- | ----------------------------------------------------------------- | +| `--cal-radius` | base border-radius | +| `--cal-container-radius` | outer container radius (multiplier of radius) | +| `--cal-spacing` | base gap/padding unit | +| `--cal-border` | stroke width | +| `--cal-shadow-{sm,md,lg}` | shadow depth (uses `var(--c-x)`) | +| `--cal-transition` | animation duration | +| `--cal-days-padding` | day-cell padding | +| `--cal-track-height` | scrollable track height | +| `--cal-day-ratio` | day-cell aspect ratio | +| `--cal-nav-padding` | padding of `CalendarNav` container (was `--header-padding`) | +| `--cal-nav-min-height` | minimum height of `CalendarNav` (was `--header-min-height`) | +| `--cal-nav-font-size` | nav container root font-size; cascades to all nav children via em | +| `--cal-nav-meta-font-size` | font-size of `.currentYear` children (year/month text in nav) | --- -## Themes (38) +## Themes (42) Generated via `scripts/generate-theme.ts` → `themes/.ts`. Do **not** hand-edit generated files; re-run `npm run build`. -**Light / bright:** `tide`, `graphite`, `mint`, `snow`, `solar`, `slate`, `neon`, `prism`, `meadow`, `latte`, `split`, `riso`, `monsoon`, `pearl`, `chalk`, `comfy`. +**Light / bright:** `tide`, `graphite`, `mint`, `snow`, `solar`, `slate`, `neon`, `prism`, `meadow`, `latte`, `split`, `riso`, `monsoon`, `pearl`, `chalk`, `comfy`, `mono`, `atelier`. -**Dark / vibrant:** `fjord`, `industrial`, `crimson`, `amethyst`, `cyber`, `espresso`, `ember`, `phosphor`, `midnight`, `sandstone`, `rosa`, `dracula`, `nebula`, `aurora`, `forest`, `scarlet`, `temporal`, `flare`, `abyss`. +**Dark / vibrant:** `fjord`, `industrial`, `crimson`, `amethyst`, `cyber`, `espresso`, `ember`, `phosphor`, `midnight`, `sandstone`, `rosa`, `dracula`, `nebula`, `aurora`, `forest`, `scarlet`, `temporal`, `flare`, `abyss`, `cobalt`, `velvet`, `eclipse`, `noir`, `bauhaus`. Range covers muted pastels (`latte`, `comfy`), neon/cyber (`phosphor`, `neon`, `abyss`), earth tones (`sandstone`, `forest`). --- -## Appearances (5) +## Appearances (7) -| Appearance | Character | Key tokens | -| ---------- | ----------------------------------------------- | ------------------------------------------------------------------- | -| `compact` | Dense, minimal padding, tight | radius 0.3em, spacing 0.35em, transition 0.15s, day-ratio 1/0.7 | -| `square` | Sharp corners, tight, minimal shadows | radius 0, spacing 0.5em, transition 0.12s | -| `soft` | Subtle rounding, balanced, gentle shadows | radius 0.75em, spacing 0.7em, transition 0.25s | -| `bubble` | Rounded, spacious, prominent shadows | radius 1.5em, spacing 0.7em, transition 0.28s, header 4em | -| `loft` | Large, relaxed | radius 1em, spacing 1em, transition 0.35s, day-padding 1.8em | -| `airy` | Borderless, light weights, generous spacing | radius 0.4em, border 1px, spacing 1em, weights 300, no shadows | +| Appearance | Character | Key tokens | +| ---------- | --------------------------------------------- | ------------------------------------------------------------------------ | +| `compact` | Dense, minimal padding, tight | radius 0.3em, spacing 0.35em, transition 0.15s, day-ratio 1/0.7 | +| `square` | Sharp corners, tight, minimal shadows | radius 0, spacing 0.5em, transition 0.12s | +| `soft` | Subtle rounding, balanced, gentle shadows | radius 0.75em, spacing 0.7em, transition 0.25s | +| `bubble` | Rounded, spacious, prominent shadows | radius 1.5em, spacing 0.7em, transition 0.28s, nav-min-height 4em | +| `loft` | Large, relaxed | radius 1em, spacing 1em, transition 0.35s, day-padding 1.8em | +| `airy` | light weights, generous spacing | radius 0.4em, border 1px, spacing 1em, weights 300, no shadows | +| `press` | Newspaper serif, sharp corners, wide tracking | radius 0.05em, serif font, letter-spacing 0.18em, no shadows, border 1px | --- @@ -134,19 +139,19 @@ Source: `src/core/layout.module.css`. ### Transition durations (per appearance) -`compact 0.15s`, `square 0.12s`, `soft 0.25s`, `bubble 0.28s`, `loft 0.35s`, `airy 0.2s`. Used by CSS transitions on opacity / transform / colors via `var(--cal-transition)`. +`compact 0.15s`, `square 0.12s`, `soft 0.25s`, `bubble 0.28s`, `loft 0.35s`, `airy 0.2s`, `press 0.18s`. Used by CSS transitions on opacity / transform / colors via `var(--cal-transition)`. ### Track scroll physics Source: `src/hooks/use-track.ts`. -| Constant | Value | Role | -| -------------- | ----- | --------------------------------- | -| `FRICTION` | 0.95 | velocity decay (inertia) | -| `SPRING_K` | 0.08 | snap stiffness | -| `SPRING_DAMP` | 0.82 | underdamped bounce | -| `RUBBER_K` | 0.12 | boundary resistance | -| `RUBBER_DAMP` | 0.75 | rubber-band damping | +| Constant | Value | Role | +| ------------- | ----- | ------------------------ | +| `FRICTION` | 0.95 | velocity decay (inertia) | +| `SPRING_K` | 0.08 | snap stiffness | +| `SPRING_DAMP` | 0.82 | underdamped bounce | +| `RUBBER_K` | 0.12 | boundary resistance | +| `RUBBER_DAMP` | 0.75 | rubber-band damping | Snap threshold ~3.5px/frame; settle tolerance 0.4px. Pointer events drive position updates. @@ -162,14 +167,14 @@ Snap threshold ~3.5px/frame; settle tolerance 0.4px. Pointer events drive positi ARIA grid pattern (https://www.w3.org/WAI/ARIA/apg/patterns/grid/). Source: `src/hooks/use-calendar-keyboard.ts`. -| Key | Action | -| -------------------- | ------------------------------------ | -| Arrow Left / Right | Move focus by one day | -| Arrow Up / Down | Move focus by one week | -| Home / End | First / last day of focused week | -| Page Up / Page Down | Previous / next month | -| Shift + Page Up/Down | Previous / next year | -| Enter or Space | Select focused day | +| Key | Action | +| -------------------- | -------------------------------- | +| Arrow Left / Right | Move focus by one day | +| Arrow Up / Down | Move focus by one week | +| Home / End | First / last day of focused week | +| Page Up / Page Down | Previous / next month | +| Shift + Page Up/Down | Previous / next year | +| Enter or Space | Select focused day | Roving tabindex: focused day = `tabindex="0"`, others `-1`. Focus crosses month boundaries via `navigateTo` unless `blockNavigation` is set. @@ -182,11 +187,11 @@ Roving tabindex: focused day = `tabindex="0"`, others `-1`. Focus crosses month Tracks (``, ``, ``): -| Key | Action | -| ------------------ | ---------------------------------- | -| Arrow Left / Right | Step backwards / forwards | -| Page Up / Down | Jump 7 items (DaysTrack) | -| Home / End | First / last allowed item | +| Key | Action | +| ------------------ | ------------------------- | +| Arrow Left / Right | Step backwards / forwards | +| Page Up / Down | Jump 7 items (DaysTrack) | +| Home / End | First / last allowed item | ### Focus management diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b214a42..0b149ff 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -141,6 +141,8 @@ import { CalendarNav, CalendarDays } from "@dateforge/react-calendar/modules"; | `locale` | `string` | `"en"` | BCP 47 language tag used for all labels and formatting | | `timeZone` | `string \| "auto"` | `"auto"` | IANA timezone (`"Europe/Paris"`, `"UTC"`), fixed offset (`"UTC+2"`, `"UTC-5"`), or `"auto"`. When `"auto"` (or omitted) the library detects the user's timezone via `Intl.DateTimeFormat().resolvedOptions().timeZone` after mount. Invalid values fall back to auto-detect with a dev warning. Affects today detection, emitted date midnight, and formatting. **Important:** with an explicit `timeZone`, the `Date` passed to `onChange` is the absolute instant of midnight **in that zone** — see [Timezone](#timezone) for examples and storage guidance | | `readOnly` | `boolean` | `false` | Disables all state-changing interactions (date/time selection). Navigation still works. Adds `data-readonly` on the root and `aria-disabled` on each interactive cell — the wrapper itself carries no ARIA state because plain `
` does not support `aria-readonly` per ARIA spec | +| `clearLabel` | `string` | `"Clear"` | Global aria-label for clear buttons. Module-level `clearLabel` props override it | +| `homeLabel` | `string` | `"Go to current month"` | Global aria-label for home/current-month buttons. Module-level `homeLabel` props override it | | `hour12` | `boolean` | `false` | Use 12-hour time format instead of 24-hour | | `timeStep` | `{ hour?: number; minute?: number; second?: number }` | `{1,1,1}` | Granularity (step) for time drums. Affects both inline `CalendarTimeGrid` and `CalendarNav` time popup. Example: `timeStep={{ minute: 5 }}` snaps minutes to 0/5/10/.../55. Step values divide the unit range; `aria-valuemax`, keyboard `±step`, and scroll snap follow the step. Default `1` (no snapping) | | `theme` | `CalendarTheme` | `"auto"` | Base value (`"auto"` / `"light"` / `"dark"`), a pre-built theme object from `@dateforge/react-calendar/themes[/name]`, or a `CustomTheme` from `createTheme()`. Named string themes (e.g. `"midnight"`) are not supported — import the object instead. | @@ -155,6 +157,89 @@ import { CalendarNav, CalendarDays } from "@dateforge/react-calendar/modules"; | `disabled` | `DisabledConfig` | — | Rules for disabling specific dates. Build with `createDisabled()` | | `children` | `React.ReactNode` | — | Module components that compose the calendar UI | +### Action aria labels + +Control aria-labels resolve as `module prop → Calendar prop → built-in default`. Use root props for a whole calendar, then override a specific module only when needed. + +Template labels support named placeholders: + +```tsx + +``` + +Date labels remain locale-derived through `Intl` where the label describes an actual date, month, year, or weekday. Week number labels use `weekLabel`. + +#### Root label props + +All of these props can be passed to ``. + +| Prop | Default | Placeholders | Used for | +| ---- | ------- | ------------ | -------- | +| `applyLabel` | `"Apply"` | — | Manual input apply buttons | +| `calendarNavigationLabel` | `"Calendar navigation"` | — | Nav toolbar when no visible `label` prop is provided | +| `changeMonthLabel` | `"Change month, currently {month}"` | `{month}` | Nav month popup trigger | +| `changeTimeLabel` | `"Change time, currently {time}"` | `{time}` | Nav time popup trigger | +| `changeYearLabel` | `"Change year, currently {year}"` | `{year}` | Nav year popup trigger | +| `clearLabel` | `"Clear"` | — | Clear buttons | +| `confirmLabel` | `"Confirm"` | — | Popup confirm buttons | +| `dayTrackLabel` | `"Day"` | — | Days track spinbutton | +| `homeLabel` | `"Go to current month"` | — | Home/current-month buttons | +| `hoursLabel` | `"Hours"` | — | Time hour drum | +| `minutesLabel` | `"Minutes"` | — | Time minute drum | +| `monthGridLabel` | `"Select month, {year}"` | `{year}` | Months grid group | +| `monthPickerLabel` | `"Month picker"` | — | Nav month picker group | +| `monthTrackLabel` | `"Month"` | — | Month track / month popup drum | +| `nextMonthLabel` | `"Next month"` | — | Nav next-month button | +| `nextYearLabel` | `"Next year"` | — | Nav next-year button | +| `nextYearsLabel` | `"Next years"` | — | Years grid next-page button | +| `previousMonthLabel` | `"Previous month"` | — | Nav previous-month button | +| `previousYearLabel` | `"Previous year"` | — | Nav previous-year button | +| `previousYearsLabel` | `"Previous years"` | — | Years grid previous-page button | +| `removeLabel` | `"Remove"` | — | Manual input per-chip remove button | +| `removeRangeEndLabel` | `"Remove range end"` | — | Selected-dates range-end remove button | +| `removeRangeStartLabel` | `"Remove range start"` | — | Selected-dates range-start remove button | +| `removeSelectedDateLabel` | `"Remove selected date"` | — | Selected date remove/toggle buttons | +| `resetTimeLabel` | `"Reset to {time}"` | `{time}` | Time reset button | +| `saveSelectedDateLabel` | `"Save selected date"` | — | Days track multi-mode save button | +| `secondsLabel` | `"Seconds"` | — | Time second drum | +| `selectMonthLabel` | `"Select month"` | — | Month popup dialog | +| `selectTimeLabel` | `"Select time"` | — | Time popup dialog | +| `selectYearLabel` | `"Select year"` | — | Year popup dialog | +| `showMoreSelectedDatesLabel` | `"Show {count} more selected dates"` | `{count}` | Selected-dates overflow chip | +| `themeSwitchToDarkLabel` | `"Switch to dark mode"` | — | Theme toggle when current theme is light | +| `themeSwitchToLightLabel` | `"Switch to light mode"` | — | Theme toggle when current theme is dark | +| `themeToggleLabel` | `"Toggle theme"` | — | Theme toggle while theme is still auto/unresolved | +| `timePeriodLabel` | `"Time period, currently {period}"` | `{period}` | AM/PM switch | +| `timePickerLabel` | `"Time picker"` | — | Time track group | +| `weekLabel` | `"Week"` | — | Days grid week-number rows and row headers | +| `yearGridLabel` | `"Select year, showing {from} to {to}"` | `{from}`, `{to}` | Years grid group | +| `yearPageNavigationLabel` | `"Year page navigation"` | — | Years grid page controls group | +| `yearPickerLabel` | `"Year picker"` | — | Nav year picker group | +| `yearTrackLabel` | `"Year"` | — | Year track / year popup drum | + +#### Module override props + +Module props use the same names and override the matching root prop only for that module instance. + +| Module | Override props | +| ------ | -------------- | +| `CalendarNav` | `calendarNavigationLabel`, `changeMonthLabel`, `changeTimeLabel`, `changeYearLabel`, `clearLabel`, `confirmLabel`, `homeLabel`, `hoursLabel`, `minutesLabel`, `monthPickerLabel`, `monthTrackLabel`, `nextMonthLabel`, `nextYearLabel`, `previousMonthLabel`, `previousYearLabel`, `secondsLabel`, `selectMonthLabel`, `selectTimeLabel`, `selectYearLabel`, `themeSwitchToDarkLabel`, `themeSwitchToLightLabel`, `themeToggleLabel`, `timePeriodLabel`, `timePickerLabel`, `yearPickerLabel`, `yearTrackLabel` | +| `CalendarDays` | `weekLabel` | +| `CalendarInfo` | `clearLabel`, `homeLabel` | +| `CalendarManualInput` | `applyLabel`, `clearLabel`, `removeLabel` | +| `CalendarSelectedDates` | `clearLabel`, `removeRangeEndLabel`, `removeRangeStartLabel`, `removeSelectedDateLabel`, `showMoreSelectedDatesLabel` | +| `CalendarDaysTrack` | `dayTrackLabel`, `removeSelectedDateLabel`, `saveSelectedDateLabel` | +| `CalendarMonthsTrack` | `monthTrackLabel` | +| `CalendarYearsTrack` | `yearTrackLabel` | +| `CalendarTimeGrid` | `hoursLabel`, `minutesLabel`, `resetTimeLabel`, `secondsLabel`, `timePeriodLabel`, `timePickerLabel` | +| `CalendarMonthsGrid` | `monthGridLabel` | +| `CalendarYearsGrid` | `nextYearsLabel`, `previousYearsLabel`, `yearGridLabel`, `yearPageNavigationLabel` | + ### When does each action fire `onChange`? The complete cross-module matrix lives in `ARCHITECTURE.md → Module behavior matrix`. It tells you for every public action — Nav arrows, day click, preset click, drum scroll, manual input commit, etc. — whether it changes the view, mutates the selection, fires the consumer callback, and how it behaves under `readOnly`. @@ -610,6 +695,7 @@ Renders the month grid — weekday headers, week numbers (optional), and the day | `boldWeekends` | `boolean` | `false` | Render Saturday and Sunday in bold with the weekend accent color (`--c-we`) | | `highlightToday` | `boolean` | `true` | Highlight today's date | | `todayDot` | `boolean` | `true` | Render a small dot under today's digit. Selected-today dot color uses gradient/active text color first, then the theme `todayDot` token. Dot size and lower inset can be tuned by appearance tokens. | +| `weekLabel` | `string` | `"Week"` | Aria-label prefix for week-number rows and row headers. The root `Calendar` `weekLabel` applies globally; this prop overrides it for this days grid | | `fixedRows` | `boolean` | `true` | Always render 6 rows of day cells | | `weekNumbers` | `boolean` | `false` | Show ISO week numbers in the leftmost column | | `hideWeekdays` | `boolean` | `false` | Hide the row of weekday name headers | @@ -668,7 +754,9 @@ Navigation header with configurable controls. | `showNowTime` | `boolean` | `false` | Show the current system time as a live read-only display (updates every second). A pulsing dot indicates it is live. Respects the `hour12` setting from `` | | `seconds` | `boolean` | `false` | Include seconds in `showTime` and `showNowTime` displays, and in the time picker popup | | `home` | `boolean` | `false` | Show a button that navigates back to today | +| `homeLabel` | `string` | global / `"Go to current month"` | aria-label for the `home` button. Overrides `` | | `clear` | `boolean` | `false` | Show a button that clears the current selection | +| `clearLabel` | `string` | global / `"Clear"` | aria-label for the `clear` button. Overrides `` | | `themeToggle` | `boolean` | `false` | Show a light/dark theme toggle button. Has no effect when a custom theme (`createTheme()` or pre-built palette) is passed to `` | | `offset` | `number` | `0` | Month offset relative to `viewDate`. Use to render two synced nav headers in `cols={2}` layouts (``) | | `col` | `number \| string` | — | CSS grid `grid-column` value | @@ -920,6 +1008,7 @@ Displays the currently selected dates as chips. Clicking a chip navigates the vi | `allowNavigate` | `boolean` | `true` | Clicking a chip navigates the calendar to that date | | `animated` | `boolean` | `true` | Animate chips appearing and disappearing | | `align` | `"left" \| "center" \| "right"` | `"left"` | Horizontal alignment of the chip list | +| `clearLabel` | `string` | global / `"Clear"` | aria-label for the clear-all button. Overrides `` | | `maxVisibleChips` | `number` | — | Maximum number of selected-date chips before overflow. Applies to `mode="multiple"` only; range chips are not collapsed | | `overflowLabel` | `string` | `"+{count}"` | Overflow chip label. `{count}` is replaced with the number of hidden selected dates | | `showTime` | `boolean` | `false` | Include time (hours and minutes) in the chip label. Respects the `hour12` setting from `` | @@ -946,6 +1035,8 @@ When `showSummary` is `true` and no custom `formatter` is passed: `showRelative` can be enabled at the same time as `showSummary`; the module renders a second compact text chip such as `in 3 days`. Relative time always uses the current selected value (`selectedDate`, first selected date, `rangeStart`, then `rangeEnd`) and compares it to today. +Keyboard: `showHome` and `allowClear` render native buttons. When both actions are available, `ArrowLeft` / `ArrowUp`, `ArrowRight` / `ArrowDown`, `Home`, and `End` move focus between them; `Enter` / `Space` activate the focused button. + ### Props | Prop | Type | Default | Description | @@ -953,9 +1044,11 @@ When `showSummary` is `true` and no custom `formatter` is passed: | `allowClear` | `boolean` | `false` | Show a clear-all button when there is a selection. Clears `null` in single mode, `[]` in multiple mode, and `{ from: null, to: null }` in range mode. Disabled in `readOnly` | | `align` | `"left" \| "center" \| "right"` | `"left"` | Horizontal alignment of the text group | | `animated` | `boolean` | `true` | Animate the module's height when content appears/disappears | +| `clearLabel` | `string` | global / `"Clear"` | aria-label for the clear-all button. Overrides `` | | `col` | `number \| string` | — | CSS grid `grid-column` value | | `emptyLabel` | `React.ReactNode` | `null` | Text/content shown when nothing is selected. Boolean values and empty strings are treated as empty | | `formatter` | `(value: CalendarInfoValue) => React.ReactNode` | — | Custom summary renderer. Receives `Date`, `Date[]`, `{ from, to }`, or `null`. Overrides the default summary only; it does not affect `showRelative` | +| `homeLabel` | `string` | global / `"Go to current month"` | aria-label for the home/current-month button. Overrides `` | | `prefix` | `React.ReactNode` | — | Small content rendered before the summary. Hidden for `emptyLabel` and hidden when there is no summary | | `rangeStyle` | `"days" \| "duration"` | `"days"` | Range-only summary style for complete ranges. `"days"` uses calendar-day difference; `"duration"` uses elapsed time (`3 days 4 hours`, etc.) | | `showHome` | `boolean` | `false` | Show a button that navigates to the current month. Does not change selection | @@ -1012,6 +1105,7 @@ Text input that lets the user type a date directly. Adapts shape to the calendar | ------------ | ------------------------------- | -------- | ------------------------------------------------------------- | | `allowClear` | `boolean` | `true` | Show a top-level clear button that wipes the entire selection | | `align` | `"left" \| "center" \| "right"` | `"left"` | Horizontal alignment of the input content | +| `clearLabel` | `string` | global / `"Clear"` | aria-label for clear controls. Overrides `` | | `col` | `number \| string` | — | CSS grid `grid-column` value | | `label` | `React.ReactNode` | — | Inline label shown before the input / selected chips | @@ -1298,8 +1392,10 @@ const myAppearance = createAppearance({ | `border` | Border width for the container and internal dividers | | `containerGap` | Gap between calendar module areas. Use `"0px"` for borderless layouts | | `spacing` | Internal padding / gap between elements | -| `headerPadding` | Padding for `CalendarNav` | -| `headerMinHeight` | Minimum height for `CalendarNav` | +| `navPadding` | Padding for `CalendarNav` | +| `navMinHeight` | Minimum height for `CalendarNav` | +| `navFontSize` | Root font-size of `CalendarNav` container — cascades to all nav children via `em` | +| `navMetaFontSize` | Font-size of the year/month text inside the nav's current selector | | `navButtonBg` | Background for month/year picker buttons in `CalendarNav` | | `font` | Font family | | `fontSize` | Base font size | diff --git a/README.md b/README.md index 6b26b56..e50abb3 100644 --- a/README.md +++ b/README.md @@ -24,24 +24,33 @@ Compact DateForge calendar
-**The modular calendar toolkit that starts tiny and grows into exactly what your product needs.** +
-Monolithic pickers ship everything: the grid, the nav, the time picker, the presets, the opinions, the layout, the weight. DateForge ships only what you use. +### [🚀 Live Demo](https://calendar-demo-pi.vercel.app/)  ·  [📖 Docs](https://calendar-demo-pi.vercel.app/docs)  ·  [📚 Storybook](https://kirilinsky.github.io/dateforge-react-calendar/) -Start with two components. Add only the pieces your workflow earns: range selection, multi-select, time, presets, manual input, selected-date chips, track pickers, custom layouts, themes, appearances, and tokens. DateForge is not one picker with a long prop list — it is a toolkit of focused modules that share one calendar brain. +
-**Modular · Composable · Tokenized** -**Start minimal. Scale infinitely. Add only the modules you need.** +**A modular calendar toolkit that starts tiny and grows with your product.** + +Monolithic pickers ship the grid, nav, time picker, presets, layout opinions, and weight. DateForge ships only what you use. -The module mix, selection modes, visual tokens, and focused props unlock **~2.0 trillion built-in calendar configurations** without forcing you into one prebuilt UI. +Start with two components. Add range selection, multi-select, time, presets, manual input, chips, tracks, custom layouts, themes, and tokens. -Use built-in themes and appearances, or create your own with first-class APIs. Shape selection with flexible presets, disabled rules, min/max bounds, timezone-aware dates, and `single`, `multiple`, or `range` modes. Build a classic picker, a date track, a 12-month range board, a month-only selector, a time-only control, or a fully custom booking flow from the same parts. +DateForge is not one picker with a long prop list. It is a set of focused modules that share one calendar brain.
+ DateForge modular architecture +
-### [📖 Docs](https://calendar-demo-pi.vercel.app/docs)  ·  [🚀 Live Demo](https://calendar-demo-pi.vercel.app/)  ·  [📚 Storybook](https://kirilinsky.github.io/dateforge-react-calendar/) +**Modular · Composable · Tokenized** +**Start minimal. Scale infinitely. Add only the modules you need.** - +The module mix, selection modes, tokens, and focused props unlock **~2.0 trillion built-in calendar configurations** without forcing one prebuilt UI. + +Use built-in themes and appearances, or create your own. Shape selection with presets, disabled rules, min/max bounds, timezones, and modes: +`single`, `multiple`, or `range`. + +Build a classic picker, date track, 12-month board, month-only selector, time-only control, or custom booking flow from the same parts.

@@ -51,16 +60,43 @@ Use built-in themes and appearances, or create your own with first-class APIs. S --- +## Install + +```bash +npm i @dateforge/react-calendar +``` + +No global CSS import is required — styles are bundled into the modules and apply automatically. + +## Quick start + +```tsx +import { useState } from "react"; +import { Calendar } from "@dateforge/react-calendar"; +import { CalendarNav, CalendarDays } from "@dateforge/react-calendar/modules"; + +export function Example() { + const [date, setDate] = useState(null); + + return ( + + + + + ); +} +``` + ## Why DateForge? Most date pickers ask you to accept their shape. DateForge lets you forge yours. -- **Ship less by default** — import a tiny `CalendarDays` grid and `CalendarNav`, then stop. No unused time picker, no bundled presets, no hidden panel waiting in your JavaScript. -- **Compose real product workflows** — add modules when the UX needs them: range previews, multi-month layouts, inline time grids, shortcuts, manual input, selected-date summaries, or mobile-friendly tracks. +- **Ship less by default** — import `CalendarDays` and `CalendarNav`, then stop. No unused time picker, presets, or hidden panel. +- **Compose real workflows** — add modules for range previews, multi-month layouts, inline time, shortcuts, manual input, summaries, or tracks. - **Keep one shared state model** — every module plugs into the same provider, so custom layouts feel native instead of stitched together. -- **Style it like your system** — themes, appearances, gradients, CSS-grid placement, and tokenized styling let the calendar look built-in, not embedded. -- **Grow without rewriting** — the same API covers a two-component date picker, a booking range calendar, a time-aware scheduler, or a dense operations tool. -- **Built for serious apps** — accessible interactions, SSR-safe defaults, timezone handling, React 18/19 support, zero runtime dependencies, and tree-shakeable module entry points. +- **Style it like your system** — themes, appearances, gradients, CSS-grid placement, and tokens help it feel built-in. +- **Grow without rewriting** — the same API covers a tiny picker, booking range calendar, scheduler, or dense operations tool. +- **Built for serious apps** — a11y, SSR-safe defaults, timezones, React 18/19, zero runtime dependencies, and tree-shakeable modules. ```tsx @@ -73,14 +109,6 @@ Most date pickers ask you to accept their shape. DateForge lets you forge yours. Remove a line, remove a feature. Add a module, add a workflow. That is the core idea. -## Install - -```bash -npm i @dateforge/react-calendar -``` - -No global CSS import is required — styles are bundled into the modules and apply automatically. - ## Modules | Module | Use it for | @@ -98,25 +126,6 @@ No global CSS import is required — styles are bundled into the modules and app | `CalendarMonthsTrack` | Scrollable month track | | `CalendarYearsTrack` | Scrollable year track | -## Quick start - -```tsx -import { useState } from "react"; -import { Calendar } from "@dateforge/react-calendar"; -import { CalendarNav, CalendarDays } from "@dateforge/react-calendar/modules"; - -export function Example() { - const [date, setDate] = useState(null); - - return ( - - - - - ); -} -``` - --- ## Links diff --git a/appearances/_wire.css b/appearances/_wire.css index 4baa675..0024331 100644 --- a/appearances/_wire.css +++ b/appearances/_wire.css @@ -5,8 +5,8 @@ --cal-border: 0px; --cal-container-gap: 0px; --cal-spacing: 1em; - --header-padding: 0.35em 1em 0.75em; - --header-min-height: 3.2em; + --cal-nav-padding: 0.35em 1em 0.75em; + --cal-nav-min-height: 3.2em; --cal-nav-button-bg: transparent; --cal-shadow-sm: none; --cal-shadow-md: none; @@ -25,8 +25,8 @@ --cal-text-sm: 0.78em; --cal-text-md: 0.88em; --cal-text-lg: 0.92em; - --cal-text-xl: 1em; - --cal-text-2xl: 1em; + --cal-nav-meta-font-size: 1em; + --cal-nav-font-size: 1em; --cal-text-day: clamp(0.9em, 4.7cqi, 1.25em); --cal-day-weight: 300; --cal-weight-regular: 300; diff --git a/appearances/airy.css b/appearances/airy.css index 5df0979..59fd780 100644 --- a/appearances/airy.css +++ b/appearances/airy.css @@ -12,8 +12,8 @@ --cal-text-sm: 0.78em; --cal-text-md: 0.86em; --cal-text-lg: 1em; - --cal-text-xl: 1.18em; - --cal-text-2xl: 1.35em; + --cal-nav-meta-font-size: 1.18em; + --cal-nav-font-size: 1.35em; --cal-text-day: clamp(0.9em, 5.2cqi, 1.4em); --cal-weight-regular: 300; --cal-weight-semibold: 300; diff --git a/appearances/bubble.css b/appearances/bubble.css index e3c6d92..a6dd999 100644 --- a/appearances/bubble.css +++ b/appearances/bubble.css @@ -2,7 +2,7 @@ [data-appearance="bubble"] { --cal-radius: 1.5em; --cal-container-radius: 2.2em; - --header-min-height: 4em; + --cal-nav-min-height: 4em; --cal-border: 1px; --cal-spacing: 0.7em; --cal-shadow-sm: 0 0.15em 0.5em var(--c-x); @@ -12,8 +12,8 @@ --cal-text-sm: 0.82em; --cal-text-md: 0.88em; --cal-text-lg: 1em; - --cal-text-xl: 1.08em; - --cal-text-2xl: 1.14em; + --cal-nav-meta-font-size: 1.08em; + --cal-nav-font-size: 1.14em; --cal-text-day: clamp(0.78em, 4.6cqi, 1.22em); --cal-leading-normal: 1.35; --cal-transition: 0.28s; diff --git a/appearances/compact.css b/appearances/compact.css index 3b718fa..666466a 100644 --- a/appearances/compact.css +++ b/appearances/compact.css @@ -12,8 +12,8 @@ --cal-text-sm: 0.76em; --cal-text-md: 0.82em; --cal-text-lg: 0.9em; - --cal-text-xl: 1em; - --cal-text-2xl: 1.04em; + --cal-nav-meta-font-size: 1em; + --cal-nav-font-size: 1.04em; --cal-text-day: clamp(0.68em, 4.2cqi, 1em); --cal-leading-tight: 1.05; --cal-leading-normal: 1.2; diff --git a/appearances/index.ts b/appearances/index.ts index e31cf07..b8e9245 100644 --- a/appearances/index.ts +++ b/appearances/index.ts @@ -2,9 +2,10 @@ import { CUSTOM_APPEARANCE_BRAND } from "../src/types/appearances"; import type { CustomAppearance } from "../src/types/appearances"; -export const airy: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "0.4em", "--cal-container-radius": "0.6em", "--cal-border": "1px", "--cal-spacing": "1em", "--cal-shadow-sm": "none", "--cal-shadow-md": "none", "--cal-shadow-lg": "none", "--cal-font-size": "clamp(13px, 3.2cqw, 20px)", "--cal-text-xs": "0.68em", "--cal-text-sm": "0.78em", "--cal-text-md": "0.86em", "--cal-text-lg": "1em", "--cal-text-xl": "1.18em", "--cal-text-2xl": "1.35em", "--cal-text-day": "clamp(0.9em, 5.2cqi, 1.4em)", "--cal-weight-regular": "300", "--cal-weight-semibold": "300", "--cal-weight-bold": "400", "--cal-leading-tight": "1.1", "--cal-leading-normal": "1.6", "--cal-leading-relaxed": "2", "--cal-transition": "0.2s", "--cal-days-padding": "1.2em", "--cal-track-height": "4em", "--cal-day-ratio": "1 / 0.55", "--cal-popup-padding": "1em", "--cal-size-chip": "2em", "--cal-size-track-item": "5.5em", "--cal-opacity-disabled": "0.3", "--cal-opacity-muted": "0.4", "--cal-opacity-hover": "0.75", "--cal-letter-spacing": "0.1em", "--cal-selected-text-dot-size": "0.18em", "--cal-selected-text-dot-offset": "0.12em", "--cal-selected-text-dot-label-shift": "0px" } }; -export const bubble: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "1.5em", "--cal-container-radius": "2.2em", "--header-min-height": "4em", "--cal-border": "1px", "--cal-spacing": "0.7em", "--cal-shadow-sm": "0 0.15em 0.5em var(--c-x)", "--cal-shadow-md": "0 0.25em 0.9em var(--c-x)", "--cal-shadow-lg": "0 0.35em 1.4em var(--c-x)", "--cal-font-size": "clamp(12px, 2.9cqw, 19px)", "--cal-text-sm": "0.82em", "--cal-text-md": "0.88em", "--cal-text-lg": "1em", "--cal-text-xl": "1.08em", "--cal-text-2xl": "1.14em", "--cal-text-day": "clamp(0.78em, 4.6cqi, 1.22em)", "--cal-leading-normal": "1.35", "--cal-transition": "0.28s", "--cal-days-padding": "1em", "--cal-track-height": "4.5em", "--cal-day-ratio": "1 / 1", "--cal-popup-padding": "0.9em", "--cal-size-chip": "2.4em", "--cal-size-track-item": "4.5em", "--cal-opacity-disabled": "0.45", "--cal-opacity-muted": "0.65", "--cal-opacity-hover": "0.82" } }; -export const compact: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "0.3em", "--cal-border": "1px", "--cal-spacing": "0.35em", "--cal-shadow-sm": "0 0.05em 0.15em var(--c-x)", "--cal-shadow-md": "0 0.1em 0.3em var(--c-x)", "--cal-shadow-lg": "0 0.1em 0.4em var(--c-x)", "--cal-font-size": "clamp(11px, 2.3cqw, 15px)", "--cal-text-2xs": "0.58em", "--cal-text-xs": "0.68em", "--cal-text-sm": "0.76em", "--cal-text-md": "0.82em", "--cal-text-lg": "0.9em", "--cal-text-xl": "1em", "--cal-text-2xl": "1.04em", "--cal-text-day": "clamp(0.68em, 4.2cqi, 1em)", "--cal-leading-tight": "1.05", "--cal-leading-normal": "1.2", "--cal-transition": "0.15s", "--cal-days-padding": "0.45em", "--cal-track-height": "3.2em", "--cal-day-ratio": "1 / 0.7", "--cal-popup-padding": "0.5em", "--cal-size-chip": "1.9em", "--cal-size-track-item": "3em", "--cal-opacity-disabled": "0.4", "--cal-opacity-muted": "0.55", "--cal-opacity-hover": "0.75" } }; -export const loft: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "1em", "--cal-container-radius": "2.5em", "--header-min-height": "5em", "--cal-border": "0px", "--cal-spacing": "1em", "--cal-shadow-sm": "0 0.3em 0.9em var(--c-x)", "--cal-shadow-md": "0 0.8em 2em var(--c-x)", "--cal-shadow-lg": "0 1.5em 3.5em var(--c-x)", "--cal-font-size": "clamp(14px, 3.5cqw, 22px)", "--cal-text-xs": "0.75em", "--cal-text-sm": "0.84em", "--cal-text-md": "0.9em", "--cal-text-lg": "1em", "--cal-text-xl": "1.12em", "--cal-text-2xl": "1.2em", "--cal-text-day": "clamp(0.82em, 4.8cqi, 1.28em)", "--cal-weight-semibold": "500", "--cal-weight-bold": "600", "--cal-leading-normal": "1.4", "--cal-leading-relaxed": "1.7", "--cal-transition": "0.35s", "--cal-days-padding": "1.8em", "--cal-track-height": "5em", "--cal-day-ratio": "1 / 1", "--cal-popup-padding": "1em", "--cal-size-chip": "2.5em", "--cal-size-track-item": "5em", "--cal-opacity-disabled": "0.35", "--cal-opacity-muted": "0.6", "--cal-opacity-hover": "0.82" } }; -export const soft: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "0.75em", "--cal-border": "1px", "--cal-spacing": "0.7em", "--cal-shadow-sm": "0 0.15em 0.5em var(--c-x)", "--cal-shadow-md": "0 0.25em 0.8em var(--c-x)", "--cal-shadow-lg": "0 0.3em 1.2em var(--c-x)", "--cal-text-lg": "0.96em", "--cal-text-xl": "1.06em", "--cal-text-day": "clamp(0.74em, 4.4cqi, 1.16em)", "--cal-leading-normal": "1.35", "--cal-transition": "0.25s", "--cal-days-padding": "0.95em", "--cal-popup-padding": "0.85em", "--cal-size-chip": "2.2em", "--cal-size-track-item": "4.2em", "--cal-opacity-disabled": "0.45", "--cal-opacity-muted": "0.65", "--cal-opacity-hover": "0.82" } }; +export const airy: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "0.4em", "--cal-container-radius": "0.6em", "--cal-border": "1px", "--cal-spacing": "1em", "--cal-shadow-sm": "none", "--cal-shadow-md": "none", "--cal-shadow-lg": "none", "--cal-font-size": "clamp(13px, 3.2cqw, 20px)", "--cal-text-xs": "0.68em", "--cal-text-sm": "0.78em", "--cal-text-md": "0.86em", "--cal-text-lg": "1em", "--cal-nav-meta-font-size": "1.18em", "--cal-nav-font-size": "1.35em", "--cal-text-day": "clamp(0.9em, 5.2cqi, 1.4em)", "--cal-weight-regular": "300", "--cal-weight-semibold": "300", "--cal-weight-bold": "400", "--cal-leading-tight": "1.1", "--cal-leading-normal": "1.6", "--cal-leading-relaxed": "2", "--cal-transition": "0.2s", "--cal-days-padding": "1.2em", "--cal-track-height": "4em", "--cal-day-ratio": "1 / 0.55", "--cal-popup-padding": "1em", "--cal-size-chip": "2em", "--cal-size-track-item": "5.5em", "--cal-opacity-disabled": "0.3", "--cal-opacity-muted": "0.4", "--cal-opacity-hover": "0.75", "--cal-letter-spacing": "0.1em", "--cal-selected-text-dot-size": "0.18em", "--cal-selected-text-dot-offset": "0.12em", "--cal-selected-text-dot-label-shift": "0px" } }; +export const bubble: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "1.5em", "--cal-container-radius": "2.2em", "--cal-nav-min-height": "4em", "--cal-border": "1px", "--cal-spacing": "0.7em", "--cal-shadow-sm": "0 0.15em 0.5em var(--c-x)", "--cal-shadow-md": "0 0.25em 0.9em var(--c-x)", "--cal-shadow-lg": "0 0.35em 1.4em var(--c-x)", "--cal-font-size": "clamp(12px, 2.9cqw, 19px)", "--cal-text-sm": "0.82em", "--cal-text-md": "0.88em", "--cal-text-lg": "1em", "--cal-nav-meta-font-size": "1.08em", "--cal-nav-font-size": "1.14em", "--cal-text-day": "clamp(0.78em, 4.6cqi, 1.22em)", "--cal-leading-normal": "1.35", "--cal-transition": "0.28s", "--cal-days-padding": "1em", "--cal-track-height": "4.5em", "--cal-day-ratio": "1 / 1", "--cal-popup-padding": "0.9em", "--cal-size-chip": "2.4em", "--cal-size-track-item": "4.5em", "--cal-opacity-disabled": "0.45", "--cal-opacity-muted": "0.65", "--cal-opacity-hover": "0.82" } }; +export const compact: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "0.3em", "--cal-border": "1px", "--cal-spacing": "0.35em", "--cal-shadow-sm": "0 0.05em 0.15em var(--c-x)", "--cal-shadow-md": "0 0.1em 0.3em var(--c-x)", "--cal-shadow-lg": "0 0.1em 0.4em var(--c-x)", "--cal-font-size": "clamp(11px, 2.3cqw, 15px)", "--cal-text-2xs": "0.58em", "--cal-text-xs": "0.68em", "--cal-text-sm": "0.76em", "--cal-text-md": "0.82em", "--cal-text-lg": "0.9em", "--cal-nav-meta-font-size": "1em", "--cal-nav-font-size": "1.04em", "--cal-text-day": "clamp(0.68em, 4.2cqi, 1em)", "--cal-leading-tight": "1.05", "--cal-leading-normal": "1.2", "--cal-transition": "0.15s", "--cal-days-padding": "0.45em", "--cal-track-height": "3.2em", "--cal-day-ratio": "1 / 0.7", "--cal-popup-padding": "0.5em", "--cal-size-chip": "1.9em", "--cal-size-track-item": "3em", "--cal-opacity-disabled": "0.4", "--cal-opacity-muted": "0.55", "--cal-opacity-hover": "0.75" } }; +export const loft: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "1em", "--cal-container-radius": "2.5em", "--cal-nav-min-height": "4.5em", "--cal-nav-padding": "0.45em 0.55em", "--cal-border": "0px", "--cal-spacing": "1em", "--cal-shadow-sm": "0 0.3em 0.9em var(--c-x)", "--cal-shadow-md": "0 0.8em 2em var(--c-x)", "--cal-shadow-lg": "0 1.5em 3.5em var(--c-x)", "--cal-font-size": "clamp(14px, 3.5cqw, 22px)", "--cal-text-xs": "0.75em", "--cal-text-sm": "0.83em", "--cal-text-md": "0.9em", "--cal-text-lg": "1em", "--cal-nav-meta-font-size": "1.05em", "--cal-nav-font-size": "1.1em", "--cal-text-day": "clamp(0.82em, 4.8cqi, 1.28em)", "--cal-weight-semibold": "500", "--cal-weight-bold": "600", "--cal-leading-normal": "1.4", "--cal-leading-relaxed": "1.7", "--cal-transition": "0.35s", "--cal-days-padding": "1.8em", "--cal-track-height": "4.5em", "--cal-day-ratio": "1 / 1", "--cal-popup-padding": "1em", "--cal-size-chip": "2.5em", "--cal-size-track-item": "4.5em", "--cal-opacity-disabled": "0.35", "--cal-opacity-muted": "0.6", "--cal-opacity-hover": "0.82" } }; +export const press: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "0.05em", "--cal-container-radius": "0.1em", "--cal-nav-min-height": "3.4em", "--cal-nav-padding": "0.35em 0.55em", "--cal-border": "1px", "--cal-spacing": "0.35em", "--cal-shadow-sm": "none", "--cal-shadow-md": "none", "--cal-shadow-lg": "none", "--cal-font": "'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif", "--cal-font-size": "clamp(14px, 3.4cqw, 22px)", "--cal-text-xs": "0.7em", "--cal-text-sm": "0.8em", "--cal-text-md": "0.86em", "--cal-text-lg": "1.03em", "--cal-nav-meta-font-size": "1em", "--cal-nav-font-size": "1.2em", "--cal-text-day": "clamp(1em, 5.2cqi, 1.54em)", "--cal-weight-semibold": "400", "--cal-weight-bold": "300", "--cal-leading-tight": "1", "--cal-leading-normal": "1.25", "--cal-leading-relaxed": "1.5", "--cal-letter-spacing": "0.18em", "--cal-transition": "0.18s", "--cal-days-padding": "1.2em", "--cal-track-height": "3.6em", "--cal-day-ratio": "1 / 1.15", "--cal-popup-padding": "0.9em", "--cal-size-chip": "2.2em", "--cal-size-track-item": "3.58em", "--cal-opacity-disabled": "0.25", "--cal-opacity-muted": "0.55", "--cal-opacity-hover": "0.78", "--cal-selected-day-weight": "600", "--cal-selected-text-dot-size": "0.22em", "--cal-selected-text-dot-offset": "0.15em", "--cal-today-outline-width": "1px" } }; +export const soft: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "0.75em", "--cal-border": "1px", "--cal-spacing": "0.7em", "--cal-shadow-sm": "0 0.15em 0.5em var(--c-x)", "--cal-shadow-md": "0 0.25em 0.8em var(--c-x)", "--cal-shadow-lg": "0 0.3em 1.2em var(--c-x)", "--cal-text-lg": "0.96em", "--cal-nav-meta-font-size": "1.06em", "--cal-text-day": "clamp(0.74em, 4.4cqi, 1.16em)", "--cal-leading-normal": "1.35", "--cal-transition": "0.25s", "--cal-days-padding": "0.95em", "--cal-popup-padding": "0.85em", "--cal-size-chip": "2.2em", "--cal-size-track-item": "4.2em", "--cal-opacity-disabled": "0.45", "--cal-opacity-muted": "0.65", "--cal-opacity-hover": "0.82" } }; export const square: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "0", "--cal-border": "1px", "--cal-spacing": "0.5em", "--cal-text-sm": "0.78em", "--cal-text-md": "0.84em", "--cal-text-lg": "0.94em", "--cal-text-day": "clamp(0.72em, 4.4cqi, 1.12em)", "--cal-leading-tight": "1", "--cal-leading-normal": "1.2", "--cal-transition": "0.12s", "--cal-day-ratio": "1 / 1", "--cal-popup-padding": "0.7em", "--cal-size-chip": "2em", "--cal-size-track-item": "3.8em", "--cal-opacity-disabled": "0.3", "--cal-opacity-muted": "0.5", "--cal-opacity-hover": "0.72", "--cal-letter-spacing": "0.04em" } }; diff --git a/appearances/loft.css b/appearances/loft.css index d0db6f5..a31a753 100644 --- a/appearances/loft.css +++ b/appearances/loft.css @@ -2,7 +2,8 @@ [data-appearance="loft"] { --cal-radius: 1em; --cal-container-radius: 2.5em; - --header-min-height: 5em; + --cal-nav-min-height: 4.5em; + --cal-nav-padding: 0.45em 0.55em; --cal-border: 0px; --cal-spacing: 1em; --cal-shadow-sm: 0 0.3em 0.9em var(--c-x); @@ -10,11 +11,11 @@ --cal-shadow-lg: 0 1.5em 3.5em var(--c-x); --cal-font-size: clamp(14px, 3.5cqw, 22px); --cal-text-xs: 0.75em; - --cal-text-sm: 0.84em; + --cal-text-sm: 0.83em; --cal-text-md: 0.9em; --cal-text-lg: 1em; - --cal-text-xl: 1.12em; - --cal-text-2xl: 1.2em; + --cal-nav-meta-font-size: 1.05em; + --cal-nav-font-size: 1.1em; --cal-text-day: clamp(0.82em, 4.8cqi, 1.28em); --cal-weight-semibold: 500; --cal-weight-bold: 600; @@ -22,11 +23,11 @@ --cal-leading-relaxed: 1.7; --cal-transition: 0.35s; --cal-days-padding: 1.8em; - --cal-track-height: 5em; + --cal-track-height: 4.5em; --cal-day-ratio: 1 / 1; --cal-popup-padding: 1em; --cal-size-chip: 2.5em; - --cal-size-track-item: 5em; + --cal-size-track-item: 4.5em; --cal-opacity-disabled: 0.35; --cal-opacity-muted: 0.6; --cal-opacity-hover: 0.82; diff --git a/appearances/press.css b/appearances/press.css new file mode 100644 index 0000000..0ca4248 --- /dev/null +++ b/appearances/press.css @@ -0,0 +1,42 @@ +@layer appearances { + [data-appearance="press"] { + --cal-radius: 0.05em; + --cal-container-radius: 0.1em; + --cal-nav-min-height: 3.4em; + --cal-nav-padding: 0.35em 0.55em; + --cal-border: 1px; + --cal-spacing: 0.35em; + --cal-shadow-sm: none; + --cal-shadow-md: none; + --cal-shadow-lg: none; + --cal-font: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif; + --cal-font-size: clamp(14px, 3.4cqw, 22px); + --cal-text-xs: 0.7em; + --cal-text-sm: 0.8em; + --cal-text-md: 0.86em; + --cal-text-lg: 1.03em; + --cal-nav-meta-font-size: 1em; + --cal-nav-font-size: 1.2em; + --cal-text-day: clamp(1em, 5.2cqi, 1.54em); + --cal-weight-semibold: 400; + --cal-weight-bold: 300; + --cal-leading-tight: 1; + --cal-leading-normal: 1.25; + --cal-leading-relaxed: 1.5; + --cal-letter-spacing: 0.18em; + --cal-transition: 0.18s; + --cal-days-padding: 1.2em; + --cal-track-height: 3.6em; + --cal-day-ratio: 1 / 1.15; + --cal-popup-padding: 0.9em; + --cal-size-chip: 2.2em; + --cal-size-track-item: 3.58em; + --cal-opacity-disabled: 0.25; + --cal-opacity-muted: 0.55; + --cal-opacity-hover: 0.78; + --cal-selected-day-weight: 600; + --cal-selected-text-dot-size: 0.22em; + --cal-selected-text-dot-offset: 0.15em; + --cal-today-outline-width: 1px; + } +} diff --git a/appearances/soft.css b/appearances/soft.css index 03b0889..a72730c 100644 --- a/appearances/soft.css +++ b/appearances/soft.css @@ -7,7 +7,7 @@ --cal-shadow-md: 0 0.25em 0.8em var(--c-x); --cal-shadow-lg: 0 0.3em 1.2em var(--c-x); --cal-text-lg: 0.96em; - --cal-text-xl: 1.06em; + --cal-nav-meta-font-size: 1.06em; --cal-text-day: clamp(0.74em, 4.4cqi, 1.16em); --cal-leading-normal: 1.35; --cal-transition: 0.25s; diff --git a/package-lock.json b/package-lock.json index e0ee06a..e66b78b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10182,9 +10182,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", "engines": { diff --git a/src/__tests__/integration/action-labels.test.tsx b/src/__tests__/integration/action-labels.test.tsx new file mode 100644 index 0000000..642b01a --- /dev/null +++ b/src/__tests__/integration/action-labels.test.tsx @@ -0,0 +1,130 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Calendar } from "@/components/calendar/calendar"; +import { CalendarDays } from "@/modules/days"; +import { CalendarDaysTrack } from "@/modules/days-track"; +import { CalendarInfo } from "@/modules/info"; +import { CalendarManualInput } from "@/modules/manual-input"; +import { CalendarMonthsGrid } from "@/modules/months-grid"; +import { CalendarMonthsTrack } from "@/modules/months-track"; +import { CalendarNav } from "@/modules/nav"; +import { CalendarSelectedDates } from "@/modules/selected-dates"; +import { CalendarTimeGrid } from "@/modules/time"; +import { CalendarYearsGrid } from "@/modules/years-grid"; +import { CalendarYearsTrack } from "@/modules/years-track"; + +const D = (y: number, m: number, d: number) => new Date(y, m, d); + +describe("action aria labels", () => { + it("uses global Calendar labels across modules", () => { + const { getAllByLabelText } = render( + + + + + + , + ); + + expect(getAllByLabelText("Текущий месяц")).toHaveLength(2); + expect(getAllByLabelText("Очистить")).toHaveLength(4); + }); + + it("lets module labels override global Calendar labels", () => { + const { getByLabelText, queryByLabelText } = render( + + + + , + ); + + expect(getByLabelText("Nav clear")).toBeTruthy(); + expect(getByLabelText("Nav home")).toBeTruthy(); + expect(getByLabelText("Info clear")).toBeTruthy(); + expect(getByLabelText("Info home")).toBeTruthy(); + expect(queryByLabelText("Global clear")).toBeNull(); + expect(queryByLabelText("Global home")).toBeNull(); + }); + + it("uses global Calendar labels for module controls", () => { + const { getAllByLabelText, getByLabelText } = render( + + + + + + + + + + , + ); + + expect(getByLabelText("Main nav")).toBeTruthy(); + expect(getByLabelText(/Open time/)).toBeTruthy(); + expect(getByLabelText("Month controls")).toBeTruthy(); + expect(getByLabelText(/Open month/)).toBeTruthy(); + expect(getByLabelText("Back month")).toBeTruthy(); + expect(getByLabelText("Forward month")).toBeTruthy(); + expect(getByLabelText("Year controls")).toBeTruthy(); + expect(getByLabelText(/Open year/)).toBeTruthy(); + expect(getByLabelText("Back year")).toBeTruthy(); + expect(getByLabelText("Forward year")).toBeTruthy(); + expect(getByLabelText("Day rail")).toBeTruthy(); + expect(getByLabelText("Remove day")).toBeTruthy(); + expect(getByLabelText("Month rail")).toBeTruthy(); + expect(getByLabelText("Year rail")).toBeTruthy(); + expect(getByLabelText("Time controls")).toBeTruthy(); + expect(getAllByLabelText(/Week row \d+/).length).toBeGreaterThan(0); + expect(getByLabelText("Hour drum")).toBeTruthy(); + expect(getByLabelText("Minute drum")).toBeTruthy(); + expect(getByLabelText("Month grid 2024")).toBeTruthy(); + expect(getByLabelText(/Year grid \d+-\d+/)).toBeTruthy(); + expect(getByLabelText("Year pages")).toBeTruthy(); + expect(getByLabelText("Back years")).toBeTruthy(); + expect(getByLabelText("Forward years")).toBeTruthy(); + }); +}); diff --git a/src/__tests__/integration/info.test.tsx b/src/__tests__/integration/info.test.tsx index 00fcd63..30ebbad 100644 --- a/src/__tests__/integration/info.test.tsx +++ b/src/__tests__/integration/info.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from "@testing-library/react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Calendar } from "@/components/calendar/calendar"; @@ -301,6 +301,61 @@ describe("CalendarInfo", () => { ).toBeNull(); }); + it("marks summary updates as an atomic status message", () => { + const { container } = render( + + + , + ); + + const status = container.querySelector( + '[data-area="calendar-info"] [role="status"]', + ); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveAttribute("aria-atomic", "true"); + }); + + it("supports arrow, Home, and End navigation between action buttons", async () => { + const { getByLabelText } = render( + + + , + ); + + const home = getByLabelText("Go to current month"); + const clear = getByLabelText("Clear"); + + home.focus(); + await userEvent.keyboard("{ArrowRight}"); + expect(clear).toHaveFocus(); + + await userEvent.keyboard("{ArrowLeft}"); + expect(home).toHaveFocus(); + + await userEvent.keyboard("{End}"); + expect(clear).toHaveFocus(); + + await userEvent.keyboard("{Home}"); + expect(home).toHaveFocus(); + }); + + it("restores focus to the remaining action after clear removes its button", async () => { + const { getByLabelText, queryByLabelText } = render( + + + , + ); + + const clear = getByLabelText("Clear"); + clear.focus(); + await userEvent.keyboard("{Enter}"); + + await waitFor(() => { + expect(queryByLabelText("Clear")).toBeNull(); + expect(getByLabelText("Go to current month")).toHaveFocus(); + }); + }); + it("emptyLabel renders when nothing selected, summary takes over when selected", () => { const { queryByText, rerender } = render( diff --git a/src/__tests__/integration/nav-bound.test.tsx b/src/__tests__/integration/nav-bound.test.tsx index 1162859..eb5079d 100644 --- a/src/__tests__/integration/nav-bound.test.tsx +++ b/src/__tests__/integration/nav-bound.test.tsx @@ -60,7 +60,7 @@ describe("CalendarNav — bound prop", () => { , ); - fireEvent.click(getByLabelText("Clear selection")); + fireEvent.click(getByLabelText("Clear")); expect(onChange).toHaveBeenCalled(); const last = onChange.mock.calls.at(-1)?.[0]; // bound=from clear → from=null, to preserved @@ -78,7 +78,7 @@ describe("CalendarNav — bound prop", () => { , ); - const btn = getByLabelText("Clear selection") as HTMLButtonElement; + const btn = getByLabelText("Clear") as HTMLButtonElement; expect(btn.disabled).toBe(true); }); diff --git a/src/__tests__/integration/readonly.test.tsx b/src/__tests__/integration/readonly.test.tsx index c98b633..2d65df9 100644 --- a/src/__tests__/integration/readonly.test.tsx +++ b/src/__tests__/integration/readonly.test.tsx @@ -21,7 +21,7 @@ describe("readOnly — Nav clear", () => { , ); - const btn = within(container).getByLabelText("Clear selection"); + const btn = within(container).getByLabelText("Clear"); expect(btn).toBeDisabled(); await userEvent.click(btn); expect(onChange).not.toHaveBeenCalled(); @@ -33,7 +33,7 @@ describe("readOnly — Nav clear", () => { , ); - const btn = within(container).getByLabelText("Clear selection"); + const btn = within(container).getByLabelText("Clear"); expect(btn).not.toBeDisabled(); }); }); diff --git a/src/__tests__/integration/week-days.test.tsx b/src/__tests__/integration/week-days.test.tsx index 256c764..1258014 100644 --- a/src/__tests__/integration/week-days.test.tsx +++ b/src/__tests__/integration/week-days.test.tsx @@ -121,6 +121,26 @@ describe("WeekDays — weekNumbers", () => { expect(row?.children.length).toBe(8); expect(row?.firstElementChild?.getAttribute("aria-hidden")).toBe("true"); }); + + it("uses the weekLabel prop for week number aria labels", () => { + const { container } = render( + + + , + ); + const weekHeaders = within(container).getAllByRole("rowheader"); + expect(weekHeaders[0]).toHaveAccessibleName(/Woche \d{2}/); + }); + + it("lets the module weekLabel override the Calendar weekLabel", () => { + const { container } = render( + + + , + ); + const weekHeaders = within(container).getAllByRole("rowheader"); + expect(weekHeaders[0]).toHaveAccessibleName(/Module week \d{2}/); + }); }); describe("WeekDays — highlightWeekends", () => { diff --git a/src/components/calendar/calendar.tsx b/src/components/calendar/calendar.tsx index e0b7da2..704deec 100644 --- a/src/components/calendar/calendar.tsx +++ b/src/components/calendar/calendar.tsx @@ -8,7 +8,11 @@ import { CUSTOM_APPEARANCE_BRAND, type CustomAppearance, } from "@/types/appearances"; -import type { CalendarMode, CalendarProps } from "@/types/calendar"; +import type { + CalendarActionLabels, + CalendarMode, + CalendarProps, +} from "@/types/calendar"; import { CUSTOM_THEME_BRAND, type CustomTheme } from "@/types/themes"; const isCustomTheme = (t: unknown): t is CustomTheme => @@ -124,6 +128,7 @@ export function Calendar({ containerWidth={containerWidth} toggleTheme={toggleTheme} activeTheme={activeTheme} + actionLabels={restProps as CalendarActionLabels} {...(restProps as import("@/types/calendar").CalendarProps)} >
void; onClose: () => void; label?: string; + confirmLabel?: string; } export const Popup = ({ @@ -18,6 +19,7 @@ export const Popup = ({ onConfirm, onClose, label = "Dialog", + confirmLabel = "Confirm", }: PopupProps) => { const { popupAnchorEl, setPopupAnchorEl, containerRef } = useUI(); const backdropRef = useRef(null); @@ -94,7 +96,7 @@ export const Popup = ({ - )} - {hasClearBtn && ( - + {hasActionGroup && ( +
+ {showHome && ( + + )} + {hasClearBtn && ( + + )} +
)}
diff --git a/src/modules/info/info.module.css b/src/modules/info/info.module.css index dbf8986..85d41fb 100644 --- a/src/modules/info/info.module.css +++ b/src/modules/info/info.module.css @@ -86,6 +86,13 @@ height: 1.1em; } + .actionsGroup { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.35em; + } + .actionBtnDisabled { color: var(--c-dt, var(--c-c)); cursor: default; diff --git a/src/modules/manual-input/date-slot.tsx b/src/modules/manual-input/date-slot.tsx index 9dda009..f63cd4a 100644 --- a/src/modules/manual-input/date-slot.tsx +++ b/src/modules/manual-input/date-slot.tsx @@ -9,6 +9,9 @@ import { MaskedDateInput } from "./masked-date-input"; interface DateSlotProps { date: Date | null; isAllowed: (d: Date) => boolean; + applyLabel: string; + clearLabel: string; + removeLabel: string; onSave: (d: Date) => void; onClear?: () => void; onRemove?: () => void; @@ -21,6 +24,9 @@ interface DateSlotProps { export const DateSlot: React.FC = ({ date, isAllowed, + applyLabel, + clearLabel, + removeLabel, onSave, onClear, onRemove, @@ -128,7 +134,7 @@ export const DateSlot: React.FC = ({ type="button" className={styles.chipRemove} onClick={onRemove} - aria-label="Remove" + aria-label={removeLabel} disabled={readOnly} > @@ -152,7 +158,7 @@ export const DateSlot: React.FC = ({ {hasText && ( diff --git a/src/modules/years-grid/index.tsx b/src/modules/years-grid/index.tsx index 27ee106..1593fd6 100644 --- a/src/modules/years-grid/index.tsx +++ b/src/modules/years-grid/index.tsx @@ -8,6 +8,14 @@ import shared from "@/global/global.module.css"; import { useRovingTileFocus } from "@/hooks/use-roving-tile-focus"; import { ChevronLeft, ChevronRight } from "@/Icons"; import type { DisabledConfig } from "@/types/calendar"; +import { + DEFAULT_NEXT_YEARS_LABEL, + DEFAULT_PREVIOUS_YEARS_LABEL, + DEFAULT_YEAR_GRID_LABEL, + DEFAULT_YEAR_PAGE_NAVIGATION_LABEL, + formatActionLabel, + resolveActionLabel, +} from "@/utils/action-labels"; import { setYear } from "@/utils/date-utils"; import { getGridSlotStyle } from "@/utils/get-grid-slot-style"; import { MAX_CALENDAR_YEAR, MIN_CALENDAR_YEAR } from "@/utils/year-range"; @@ -20,6 +28,10 @@ export interface CalendarYearsGridProps { disableOutOfRange?: boolean; hideOutOfRange?: boolean; col?: number | string; + nextYearsLabel?: string; + previousYearsLabel?: string; + yearGridLabel?: string; + yearPageNavigationLabel?: string; /** * Fires after the user clicks a year cell. Receives the navigated viewDate * (same month/day, picked year). Use this for a standalone year-picker UX @@ -71,6 +83,10 @@ export const CalendarYearsGrid: React.FC = ({ disableOutOfRange = true, hideOutOfRange = false, col, + nextYearsLabel, + previousYearsLabel, + yearGridLabel, + yearPageNavigationLabel, onYearSelect, }) => { const pageSize = Math.min(40, Math.max(1, yearsPerPage)); @@ -85,7 +101,27 @@ export const CalendarYearsGrid: React.FC = ({ ` is out of the supported 1..40 integer range. Clamped to ${pageSize}.`, ); } - const { minDate, maxDate, disabled } = useConfig(); + const { minDate, maxDate, disabled, actionLabels } = useConfig(); + const resolvedYearGridLabel = resolveActionLabel( + yearGridLabel, + actionLabels.yearGridLabel, + DEFAULT_YEAR_GRID_LABEL, + ); + const resolvedYearPageNavigationLabel = resolveActionLabel( + yearPageNavigationLabel, + actionLabels.yearPageNavigationLabel, + DEFAULT_YEAR_PAGE_NAVIGATION_LABEL, + ); + const resolvedPreviousYearsLabel = resolveActionLabel( + previousYearsLabel, + actionLabels.previousYearsLabel, + DEFAULT_PREVIOUS_YEARS_LABEL, + ); + const resolvedNextYearsLabel = resolveActionLabel( + nextYearsLabel, + actionLabels.nextYearsLabel, + DEFAULT_NEXT_YEARS_LABEL, + ); const { viewDate, navigateTo } = useNavigation(); const { selectedDates, rangeStart, rangeEnd } = useSelectionValue(); @@ -205,20 +241,24 @@ export const CalendarYearsGrid: React.FC = ({ data-area="years-grid" className={styles.root} role="group" - aria-label={`Select year, showing ${pageStartYear} to ${pageEndYear}`} + aria-label={formatActionLabel( + formatActionLabel(resolvedYearGridLabel, "from", pageStartYear), + "to", + pageEndYear, + )} style={getGridSlotStyle(col)} > {showControls && (