From 586b573691273c0df21602693f3d9b4c33444177 Mon Sep 17 00:00:00 2001 From: kirilinsky Date: Tue, 19 May 2026 10:07:44 +0200 Subject: [PATCH 1/9] feat: add aria label cascade --- DOCUMENTATION.md | 8 +++ .../integration/action-labels.test.tsx | 56 +++++++++++++++++++ src/__tests__/integration/nav-bound.test.tsx | 4 +- src/__tests__/integration/readonly.test.tsx | 4 +- src/context/config-context.tsx | 3 +- src/core/provider.tsx | 8 +++ src/modules/info/index.tsx | 25 ++++++++- src/modules/manual-input/date-slot.tsx | 4 +- src/modules/manual-input/index.tsx | 28 +++++++++- src/modules/nav/index.tsx | 34 +++++++++-- src/modules/selected-dates/index.tsx | 24 +++++++- src/types/calendar.ts | 7 +++ src/utils/action-labels.ts | 12 ++++ 13 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 src/__tests__/integration/action-labels.test.tsx create mode 100644 src/utils/action-labels.ts diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b214a42..31349c4 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. | @@ -668,7 +670,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 +924,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 `` | @@ -953,9 +958,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 +1019,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 | diff --git a/src/__tests__/integration/action-labels.test.tsx b/src/__tests__/integration/action-labels.test.tsx new file mode 100644 index 0000000..ec4d731 --- /dev/null +++ b/src/__tests__/integration/action-labels.test.tsx @@ -0,0 +1,56 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Calendar } from "@/components/calendar/calendar"; +import { CalendarInfo } from "@/modules/info"; +import { CalendarManualInput } from "@/modules/manual-input"; +import { CalendarNav } from "@/modules/nav"; +import { CalendarSelectedDates } from "@/modules/selected-dates"; + +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(); + }); +}); 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/context/config-context.tsx b/src/context/config-context.tsx index 628a2c0..697e16b 100644 --- a/src/context/config-context.tsx +++ b/src/context/config-context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from "react"; -import type { DisabledConfig } from "@/types/calendar"; +import type { CalendarActionLabels, DisabledConfig } from "@/types/calendar"; export interface CalendarConfig { locale: string; @@ -15,6 +15,7 @@ export interface CalendarConfig { timeZone?: string; readOnly: boolean; timeStep?: { hour?: number; minute?: number; second?: number }; + actionLabels: CalendarActionLabels; } export const ConfigContext = createContext( diff --git a/src/core/provider.tsx b/src/core/provider.tsx index ec69f62..3771537 100644 --- a/src/core/provider.tsx +++ b/src/core/provider.tsx @@ -78,6 +78,8 @@ export function CalendarProvider({ timeZone, readOnly = false, timeStep, + clearLabel, + homeLabel, }: CalendarProps & { children: ReactNode; containerWidth?: number; @@ -407,6 +409,10 @@ export function CalendarProvider({ timeZone: resolvedTimeZone, readOnly, timeStep, + actionLabels: { + clearLabel, + homeLabel, + }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -425,6 +431,8 @@ export function CalendarProvider({ timeStep?.hour, timeStep?.minute, timeStep?.second, + clearLabel, + homeLabel, ], ); diff --git a/src/modules/info/index.tsx b/src/modules/info/index.tsx index e090c68..c4b2346 100644 --- a/src/modules/info/index.tsx +++ b/src/modules/info/index.tsx @@ -9,6 +9,10 @@ import { import shared from "@/global/global.module.css"; import { useToday } from "@/hooks/use-today"; import { Home } from "@/Icons"; +import { + DEFAULT_CALENDAR_ACTION_LABELS, + resolveActionLabel, +} from "@/utils/action-labels"; import { getGridSlotStyle } from "@/utils/get-grid-slot-style"; import { type AlignValue, alignToJustify } from "@/utils/layout-utils"; import styles from "./info.module.css"; @@ -48,9 +52,11 @@ export interface CalendarInfoProps { allowClear?: boolean; align?: AlignValue; animated?: boolean; + clearLabel?: string; col?: number | string; emptyLabel?: React.ReactNode; formatter?: CalendarInfoFormatter; + homeLabel?: string; prefix?: React.ReactNode; rangeStyle?: CalendarInfoRangeStyle; showHome?: boolean; @@ -62,9 +68,11 @@ export const CalendarInfo: React.FC = ({ allowClear = false, align = "left", animated = true, + clearLabel, col, emptyLabel = null, formatter, + homeLabel, prefix, rangeStyle = "days", showHome = false, @@ -76,7 +84,18 @@ export const CalendarInfo: React.FC = ({ const contentGroupRef = useRef(null); const homeBtnRef = useRef(null); const clearBtnRef = useRef(null); - const { locale, multiselect, range, readOnly, timeZone } = useConfig(); + const { locale, multiselect, range, readOnly, timeZone, actionLabels } = + useConfig(); + const resolvedClearLabel = resolveActionLabel( + clearLabel, + actionLabels.clearLabel, + DEFAULT_CALENDAR_ACTION_LABELS.clearLabel, + ); + const resolvedHomeLabel = resolveActionLabel( + homeLabel, + actionLabels.homeLabel, + DEFAULT_CALENDAR_ACTION_LABELS.homeLabel, + ); const { viewDate, navigateTo } = useNavigation(); const today = useToday(); const { selectedDate, selectedDates, rangeStart, rangeEnd } = @@ -275,7 +294,7 @@ export const CalendarInfo: React.FC = ({
-**The modular calendar toolkit that starts tiny and grows into exactly what your product needs.** +**A modular calendar toolkit that starts tiny and grows with your product.** -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. +Monolithic pickers ship the grid, nav, time picker, presets, layout opinions, and weight. DateForge ships only what you use. -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. +Start with two components. Add range selection, multi-select, time, presets, manual input, chips, tracks, custom layouts, themes, and tokens. + +DateForge is not one picker with a long prop list. It is a set of focused modules that share one calendar brain. **Modular · Composable · Tokenized** **Start minimal. Scale infinitely. Add only the modules you need.** -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. +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`. -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. +Build a classic picker, date track, 12-month board, month-only selector, time-only control, or custom booking flow from the same parts.
@@ -55,12 +60,12 @@ Use built-in themes and appearances, or create your own with first-class APIs. S 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 diff --git a/appearances/index.ts b/appearances/index.ts index e31cf07..eb26f70 100644 --- a/appearances/index.ts +++ b/appearances/index.ts @@ -5,6 +5,6 @@ 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 loft: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { "--cal-radius": "1em", "--cal-container-radius": "2.5em", "--header-min-height": "4.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.83em", "--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": "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 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 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..520bf47 100644 --- a/appearances/loft.css +++ b/appearances/loft.css @@ -2,7 +2,7 @@ [data-appearance="loft"] { --cal-radius: 1em; --cal-container-radius: 2.5em; - --header-min-height: 5em; + --header-min-height: 4.5em; --cal-border: 0px; --cal-spacing: 1em; --cal-shadow-sm: 0 0.3em 0.9em var(--c-x); @@ -10,7 +10,7 @@ --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; @@ -22,11 +22,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/src/__tests__/integration/action-labels.test.tsx b/src/__tests__/integration/action-labels.test.tsx index ec4d731..4678011 100644 --- a/src/__tests__/integration/action-labels.test.tsx +++ b/src/__tests__/integration/action-labels.test.tsx @@ -1,10 +1,16 @@ import { render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { Calendar } from "@/components/calendar/calendar"; +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); @@ -53,4 +59,68 @@ describe("action aria labels", () => { expect(queryByLabelText("Global clear")).toBeNull(); expect(queryByLabelText("Global home")).toBeNull(); }); + + it("uses global Calendar labels for module controls", () => { + const { 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(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/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 = ({
- {showHome && ( - - )} - {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 baaa337..f63cd4a 100644 --- a/src/modules/manual-input/date-slot.tsx +++ b/src/modules/manual-input/date-slot.tsx @@ -9,7 +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; @@ -22,7 +24,9 @@ interface DateSlotProps { export const DateSlot: React.FC = ({ date, isAllowed, + applyLabel, clearLabel, + removeLabel, onSave, onClear, onRemove, @@ -130,7 +134,7 @@ export const DateSlot: React.FC = ({ type="button" className={styles.chipRemove} onClick={onRemove} - aria-label="Remove" + aria-label={removeLabel} disabled={readOnly} > @@ -178,7 +182,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 && (
+
+ +### [🚀 Live Demo](https://calendar-demo-pi.vercel.app/)  ·  [📖 Docs](https://calendar-demo-pi.vercel.app/docs)  ·  [📚 Storybook](https://kirilinsky.github.io/dateforge-react-calendar/) + +
+ **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. @@ -32,6 +38,10 @@ Start with two components. Add range selection, multi-select, time, presets, man 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 +
+ **Modular · Composable · Tokenized** **Start minimal. Scale infinitely. Add only the modules you need.** @@ -42,12 +52,6 @@ Use built-in themes and appearances, or create your own. Shape selection with pr Build a classic picker, date track, 12-month board, month-only selector, time-only control, or custom booking flow from the same parts. -
- -### [📖 Docs](https://calendar-demo-pi.vercel.app/docs)  ·  [🚀 Live Demo](https://calendar-demo-pi.vercel.app/)  ·  [📚 Storybook](https://kirilinsky.github.io/dateforge-react-calendar/) - -
-


@@ -56,6 +60,33 @@ Build a classic picker, date track, 12-month board, month-only selector, time-on --- +## 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. @@ -78,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 | @@ -103,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 5a835cc..5909014 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": "4.5em", "--header-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-text-xl": "1.05em", "--cal-text-2xl": "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 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": "4.6em", "--cal-nav-padding": "0.5em 0.7em", "--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.05em", "--cal-nav-meta-font-size": "1.25em", "--cal-nav-font-size": "1.55em", "--cal-text-day": "clamp(1em, 5.2cqi, 1.55em)", "--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.6em", "--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 2865176..a31a753 100644 --- a/appearances/loft.css +++ b/appearances/loft.css @@ -2,8 +2,8 @@ [data-appearance="loft"] { --cal-radius: 1em; --cal-container-radius: 2.5em; - --header-min-height: 4.5em; - --header-padding: 0.45em 0.55em; + --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); @@ -14,8 +14,8 @@ --cal-text-sm: 0.83em; --cal-text-md: 0.9em; --cal-text-lg: 1em; - --cal-text-xl: 1.05em; - --cal-text-2xl: 1.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; diff --git a/appearances/press.css b/appearances/press.css new file mode 100644 index 0000000..96a85b8 --- /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: 4.6em; + --cal-nav-padding: 0.5em 0.7em; + --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.05em; + --cal-nav-meta-font-size: 1.25em; + --cal-nav-font-size: 1.55em; + --cal-text-day: clamp(1em, 5.2cqi, 1.55em); + --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.6em; + --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/src/core/layout.module.css b/src/core/layout.module.css index cb39a8a..6446d55 100644 --- a/src/core/layout.module.css +++ b/src/core/layout.module.css @@ -86,8 +86,8 @@ --cal-text-md: 0.85em; --cal-text-base: 0.9em; --cal-text-lg: 0.95em; - --cal-text-xl: 1.05em; - --cal-text-2xl: 1.1em; + --cal-nav-font-size: 1.1em; + --cal-nav-meta-font-size: 1.05em; --cal-text-day: clamp(0.72em, 4.4cqi, 1.15em); --cal-weight-regular: 400; --cal-weight-medium: 500; diff --git a/src/modules/nav/nav.module.css b/src/modules/nav/nav.module.css index 62f67a2..bd1abde 100644 --- a/src/modules/nav/nav.module.css +++ b/src/modules/nav/nav.module.css @@ -11,9 +11,9 @@ justify-content: space-between; flex-wrap: wrap; gap: 0.35em; - font-size: var(--cal-text-2xl); - padding: var(--header-padding, 0.45em 0.7em); - min-height: var(--header-min-height, 3.2em); + font-size: var(--cal-nav-font-size); + padding: var(--cal-nav-padding, 0.45em 0.7em); + min-height: var(--cal-nav-min-height, 3.2em); height: auto; z-index: 3; position: relative; @@ -70,7 +70,7 @@ } .currentYear > * { - font-size: var(--cal-text-xl); + font-size: var(--cal-nav-meta-font-size); } .arrow { diff --git a/src/stories/theming/matrix.stories.tsx b/src/stories/theming/matrix.stories.tsx index b117489..17b0d6e 100644 --- a/src/stories/theming/matrix.stories.tsx +++ b/src/stories/theming/matrix.stories.tsx @@ -149,7 +149,7 @@ AppearancesOverview.storyName = "All appearances (default theme)"; // Proof that theme + appearance token cascades reach the rendered DOM. // Without this, `toBeVisible` passes on an unstyled component — we'd have -// no signal if a token regressed (e.g. loft skipping `--cal-text-2xl`). +// no signal if a token regressed (e.g. loft skipping `--cal-nav-font-size`). export const CssCheck: Story = { render: () => { const [date, setDate] = useState(FIXED_DATE); @@ -176,8 +176,10 @@ export const CssCheck: Story = { await expect(style.getPropertyValue("--c-h").trim()).toBe( "rgb(255, 94, 94)", ); - // Loft appearance must override --cal-text-2xl on .calendarContainer. - await expect(style.getPropertyValue("--cal-text-2xl").trim()).toBe("1.1em"); + // Loft appearance must override --cal-nav-font-size on .calendarContainer. + await expect(style.getPropertyValue("--cal-nav-font-size").trim()).toBe( + "1.1em", + ); }, }; CssCheck.storyName = "CssCheck — theme + appearance cascade"; diff --git a/src/types/appearances.ts b/src/types/appearances.ts index 40a34f2..d17ea37 100644 --- a/src/types/appearances.ts +++ b/src/types/appearances.ts @@ -4,8 +4,10 @@ export type AppearanceTokens = { border: string; containerGap: string; spacing: string; - headerPadding: string; - headerMinHeight: string; + navPadding: string; + navMinHeight: string; + navFontSize: string; + navMetaFontSize: string; navButtonBg: string; shadowSm: string; shadowMd: string; @@ -38,8 +40,10 @@ export const APPEARANCE_TOKEN_TO_VAR: Record = { border: "--cal-border", containerGap: "--cal-container-gap", spacing: "--cal-spacing", - headerPadding: "--header-padding", - headerMinHeight: "--header-min-height", + navPadding: "--cal-nav-padding", + navMinHeight: "--cal-nav-min-height", + navFontSize: "--cal-nav-font-size", + navMetaFontSize: "--cal-nav-meta-font-size", navButtonBg: "--cal-nav-button-bg", shadowSm: "--cal-shadow-sm", shadowMd: "--cal-shadow-md", From 9aefd1e3307a76eb9ca8b0157a9cd095a7f9d37b Mon Sep 17 00:00:00 2001 From: kirilinsky Date: Tue, 19 May 2026 14:35:14 +0200 Subject: [PATCH 6/9] feat: add new themes --- DESIGN.md | 6 +++--- src/stories/theming/matrix.stories.tsx | 4 ++++ themes/index.ts | 2 ++ themes/themes.ts | 2 ++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 4727dde..7aedb40 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -83,13 +83,13 @@ Source: `appearances/index.ts`. --- -## 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`). diff --git a/src/stories/theming/matrix.stories.tsx b/src/stories/theming/matrix.stories.tsx index 17b0d6e..b8b13ef 100644 --- a/src/stories/theming/matrix.stories.tsx +++ b/src/stories/theming/matrix.stories.tsx @@ -30,6 +30,8 @@ const lightThemeEntries = [ ["chalk", themes.chalk], ["split", themes.split], ["riso", themes.riso], + ["mono", themes.mono], + ["atelier", themes.atelier], ] satisfies readonly (readonly [string, CalendarTheme])[]; const darkThemeEntries = [ @@ -52,6 +54,8 @@ const darkThemeEntries = [ ["fjord", themes.fjord], ["velvet", themes.velvet], ["eclipse", themes.eclipse], + ["noir", themes.noir], + ["bauhaus", themes.bauhaus], ] satisfies readonly (readonly [string, CalendarTheme])[]; const appearanceEntries = Object.entries(appearances) as [ diff --git a/themes/index.ts b/themes/index.ts index 5956767..28eb182 100644 --- a/themes/index.ts +++ b/themes/index.ts @@ -42,3 +42,5 @@ export const velvet: CustomTheme = { [CUSTOM_THEME_BRAND]: true, vars: { "--c-a" export const eclipse: CustomTheme = { [CUSTOM_THEME_BRAND]: true, vars: { "--c-a":"#f4ffd8","--c-at":"#080d09","--c-t-d":"#080d09","--c-b":"#080d09","--c-h":"#b7e000","--c-t":"#141a12","--c-c":"#e9f2c7","--c-s":"#304024","--c-x":"#b7e00024","--c-d":"#3b442e","--c-m":"#7f866d","--c-dt":"#858c72","--c-we":"#ff5d8f","--c-r":"#a78bfa","--c-e":"#ff3d68" } }; export const mono: CustomTheme = { [CUSTOM_THEME_BRAND]: true, vars: { "--c-a":"#ffffff","--c-at":"#ffffff","--c-t-d":"#ffffff","--c-b":"#ffffff","--c-h":"#111111","--c-t":"#f5f5f5","--c-c":"#111111","--c-s":"#e8e8e8","--c-x":"#00000010","--c-d":"#cccccc","--c-m":"#707070","--c-dt":"#707070","--c-we":"#111111","--c-r":"#111111","--c-e":"#cc0000","--c-oom":"#707070" } }; export const noir: CustomTheme = { [CUSTOM_THEME_BRAND]: true, vars: { "--c-a":"#111111","--c-at":"#111111","--c-t-d":"#111111","--c-b":"#111111","--c-h":"#ffffff","--c-t":"#1c1c1c","--c-c":"#e8e8e8","--c-s":"#2a2a2a","--c-x":"#ffffff08","--c-d":"#444444","--c-m":"#858585","--c-dt":"#858585","--c-we":"#e8e8e8","--c-r":"#e8e8e8","--c-e":"#ff4444","--c-oom":"#858585" } }; +export const atelier: CustomTheme = { [CUSTOM_THEME_BRAND]: true, vars: { "--c-a":"#1a1c2a","--c-at":"#ede2c2","--c-t-d":"#c2241c","--c-b":"#ede2c2","--c-h":"#1a1c2a","--c-t":"#e2d4ad","--c-c":"#1a1c2a","--c-s":"#b29766","--c-x":"#1a1c2a1c","--c-d":"#c6b687","--c-m":"#4d4530","--c-dt":"#5a4f30","--c-we":"#c2241c","--c-r":"#d4a84a","--c-e":"#9a1f17","--c-oom":"#5a4f30" } }; +export const bauhaus: CustomTheme = { [CUSTOM_THEME_BRAND]: true, vars: { "--c-a":"#d8d1b8","--c-at":"#161420","--c-t-d":"#c2241c","--c-b":"#161420","--c-h":"#d8d1b8","--c-t":"#21202e","--c-c":"#d8d1b8","--c-s":"#363448","--c-x":"#1614202a","--c-d":"#2e2d3c","--c-m":"#9a98a8","--c-dt":"#8a8898","--c-we":"#e35846","--c-r":"#5a7090","--c-e":"#ff6b6b","--c-oom":"#8a8898" } }; diff --git a/themes/themes.ts b/themes/themes.ts index d561e9c..c20e97c 100644 --- a/themes/themes.ts +++ b/themes/themes.ts @@ -85,4 +85,6 @@ export const THEMES_DATA: Record = { eclipse: { accent: "#f4ffd8", activeText: "#080d09", todayDot: "#080d09", backdrop: "#080d09", highlight: "#b7e000", tone: "#141a12", text: "#e9f2c7", stroke: "#304024", shadow: "#b7e00024", disabled: "#3b442e", mutedText: "#7f866d", disabledText: "#858c72", weekend: "#ff5d8f", range: "#a78bfa", error: "#ff3d68" }, mono: { accent: W, activeText: W, todayDot: W, backdrop: W, highlight: "#111111", tone: "#f5f5f5", text: "#111111", stroke: "#e8e8e8", shadow: "#00000010", disabled: "#cccccc", mutedText: "#707070", disabledText: "#707070", weekend: "#111111", range: "#111111", error: "#cc0000", outOfMonth: "#707070" }, noir: { accent: "#111111", activeText: "#111111", todayDot: "#111111", backdrop: "#111111", highlight: W, tone: "#1c1c1c", text: "#e8e8e8", stroke: "#2a2a2a", shadow: "#ffffff08", disabled: "#444444", mutedText: "#858585", disabledText: "#858585", weekend: "#e8e8e8", range: "#e8e8e8", error: "#ff4444", outOfMonth: "#858585" }, +atelier: { accent: "#1a1c2a", activeText: "#ede2c2", todayDot: "#c2241c", backdrop: "#ede2c2", highlight: "#1a1c2a", tone: "#e2d4ad", text: "#1a1c2a", stroke: "#b29766", shadow: "#1a1c2a1c", disabled: "#c6b687", mutedText: "#4d4530", disabledText: "#5a4f30", weekend: "#c2241c", range: "#d4a84a", error: "#9a1f17", outOfMonth: "#5a4f30" }, +bauhaus: { accent: "#d8d1b8", activeText: "#161420", todayDot: "#c2241c", backdrop: "#161420", highlight: "#d8d1b8", tone: "#21202e", text: "#d8d1b8", stroke: "#363448", shadow: "#1614202a", disabled: "#2e2d3c", mutedText: "#9a98a8", disabledText: "#8a8898", weekend: "#e35846", range: "#5a7090", error: "#ff6b6b", outOfMonth: "#8a8898" }, }; From 8066029ca282aefa60df9e32ecdc80b5ffbfbefc Mon Sep 17 00:00:00 2001 From: kirilinsky Date: Tue, 19 May 2026 14:58:52 +0200 Subject: [PATCH 7/9] feat: change press appearance --- appearances/index.ts | 2 +- appearances/press.css | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/appearances/index.ts b/appearances/index.ts index 5909014..b8e9245 100644 --- a/appearances/index.ts +++ b/appearances/index.ts @@ -6,6 +6,6 @@ export const airy: CustomAppearance = { [CUSTOM_APPEARANCE_BRAND]: true, vars: { 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": "4.6em", "--cal-nav-padding": "0.5em 0.7em", "--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.05em", "--cal-nav-meta-font-size": "1.25em", "--cal-nav-font-size": "1.55em", "--cal-text-day": "clamp(1em, 5.2cqi, 1.55em)", "--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.6em", "--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 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/press.css b/appearances/press.css index 96a85b8..0ca4248 100644 --- a/appearances/press.css +++ b/appearances/press.css @@ -2,8 +2,8 @@ [data-appearance="press"] { --cal-radius: 0.05em; --cal-container-radius: 0.1em; - --cal-nav-min-height: 4.6em; - --cal-nav-padding: 0.5em 0.7em; + --cal-nav-min-height: 3.4em; + --cal-nav-padding: 0.35em 0.55em; --cal-border: 1px; --cal-spacing: 0.35em; --cal-shadow-sm: none; @@ -14,10 +14,10 @@ --cal-text-xs: 0.7em; --cal-text-sm: 0.8em; --cal-text-md: 0.86em; - --cal-text-lg: 1.05em; - --cal-nav-meta-font-size: 1.25em; - --cal-nav-font-size: 1.55em; - --cal-text-day: clamp(1em, 5.2cqi, 1.55em); + --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; @@ -30,7 +30,7 @@ --cal-day-ratio: 1 / 1.15; --cal-popup-padding: 0.9em; --cal-size-chip: 2.2em; - --cal-size-track-item: 3.6em; + --cal-size-track-item: 3.58em; --cal-opacity-disabled: 0.25; --cal-opacity-muted: 0.55; --cal-opacity-hover: 0.78; From fb485d70884cd03dc22bad70c39badcae3b70978 Mon Sep 17 00:00:00 2001 From: kirilinsky Date: Tue, 19 May 2026 15:16:10 +0200 Subject: [PATCH 8/9] feat: update aria labels --- .size-limit.json | 2 +- DOCUMENTATION.md | 6 ++- .../integration/action-labels.test.tsx | 6 ++- src/__tests__/integration/week-days.test.tsx | 20 +++++++++ src/modules/days/helpers.ts | 13 ++++++ src/modules/days/index.tsx | 16 ++++++- src/modules/nav/index.tsx | 45 +++++++++++-------- src/types/calendar.ts | 2 + src/utils/action-labels.ts | 1 + themes/index.ts | 4 +- themes/themes.ts | 4 +- 11 files changed, 92 insertions(+), 27 deletions(-) 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/DOCUMENTATION.md b/DOCUMENTATION.md index bf870fb..0b149ff 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -167,11 +167,12 @@ Template labels support named placeholders: ``` -Date labels remain locale-derived through `Intl` where the label describes an actual date, month, year, weekday, or week number. +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 @@ -215,6 +216,7 @@ All of these props can be passed to ``. | `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 | @@ -227,6 +229,7 @@ Module props use the same names and override the matching root prop only for tha | 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` | @@ -692,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 | diff --git a/src/__tests__/integration/action-labels.test.tsx b/src/__tests__/integration/action-labels.test.tsx index 4678011..642b01a 100644 --- a/src/__tests__/integration/action-labels.test.tsx +++ b/src/__tests__/integration/action-labels.test.tsx @@ -1,6 +1,7 @@ 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"; @@ -61,7 +62,7 @@ describe("action aria labels", () => { }); it("uses global Calendar labels for module controls", () => { - const { getByLabelText } = render( + const { getAllByLabelText, getByLabelText } = render( { removeSelectedDateLabel="Remove day" saveSelectedDateLabel="Save day" timePickerLabel="Time controls" + weekLabel="Week row" yearGridLabel="Year grid {from}-{to}" yearPageNavigationLabel="Year pages" yearPickerLabel="Year controls" yearTrackLabel="Year rail" > + @@ -115,6 +118,7 @@ describe("action aria labels", () => { 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(); 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/modules/days/helpers.ts b/src/modules/days/helpers.ts index 9da4a04..e631b74 100644 --- a/src/modules/days/helpers.ts +++ b/src/modules/days/helpers.ts @@ -1,6 +1,19 @@ +import { DEFAULT_WEEK_LABEL, resolveActionLabel } from "@/utils/action-labels"; import { toTZMidnight } from "@/utils/tz-utils"; const DAY_MS = 86400000; + +export function resolveWeekLabel( + moduleLabel: string | undefined, + globalLabel: string | undefined, +): string { + return resolveActionLabel(moduleLabel, globalLabel, DEFAULT_WEEK_LABEL); +} + +export function getWeekAriaLabel(label: string, weekNumber: string): string { + return `${label} ${weekNumber}`; +} + export function getStartOfDayT(d: Date): number { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); } diff --git a/src/modules/days/index.tsx b/src/modules/days/index.tsx index 3958f74..dda639f 100644 --- a/src/modules/days/index.tsx +++ b/src/modules/days/index.tsx @@ -29,8 +29,10 @@ import { computeSwipeDirection, getEndOfDayT, getStartOfDayT, + getWeekAriaLabel, isDayHiddenByBounds, passesRangeLimits, + resolveWeekLabel, } from "./helpers"; import WeekDays from "./week-days"; @@ -51,6 +53,7 @@ export interface CalendarDaysProps { fixedRows?: boolean; blockNavigation?: boolean; todayDot?: boolean; + weekLabel?: string; /** * When a day is clicked, also move the calendar's viewDate to that day's * month. Defaults to `true` for the primary grid (`offset === 0`) and @@ -77,6 +80,7 @@ export const CalendarDays: React.FC = ({ fixedRows = true, blockNavigation = false, todayDot = true, + weekLabel, syncViewOnSelect, }) => { const effectiveSyncView = syncViewOnSelect ?? offset === 0; @@ -92,7 +96,9 @@ export const CalendarDays: React.FC = ({ timeZone, readOnly, multiselect, + actionLabels, } = useConfig(); + const resolvedWeekLabel = resolveWeekLabel(weekLabel, actionLabels.weekLabel); const { viewDate: rawDate, navigateTo } = useNavigation(); @@ -455,14 +461,20 @@ export const CalendarDays: React.FC = ({ ? { role: "presentation" as const } : { role: "row" as const, - "aria-label": `Week ${week.weekNumber}`, + "aria-label": getWeekAriaLabel( + resolvedWeekLabel, + week.weekNumber, + ), }; return (
{weekNumbers && (
{week.weekNumber} diff --git a/src/modules/nav/index.tsx b/src/modules/nav/index.tsx index caf732a..3b4626b 100644 --- a/src/modules/nav/index.tsx +++ b/src/modules/nav/index.tsx @@ -55,6 +55,7 @@ import { DEFAULT_TIME_PICKER_LABEL, DEFAULT_YEAR_PICKER_LABEL, DEFAULT_YEAR_TRACK_LABEL, + formatActionLabel, resolveActionLabel, } from "@/utils/action-labels"; import { clampBoundDate } from "@/utils/clamp-bound-date"; @@ -528,18 +529,6 @@ export const CalendarNav: React.FC = ({ const monthNameShort = getDateTimeFormat(locale, { month: "short" }).format( date, ); - const changeTimeAriaLabel = resolvedChangeTimeLabel.replaceAll( - "{time}", - curTime, - ); - const changeMonthAriaLabel = resolvedChangeMonthLabel.replaceAll( - "{month}", - monthNameLong, - ); - const changeYearAriaLabel = resolvedChangeYearLabel.replaceAll( - "{year}", - String(cur), - ); const ch = (v: number) => navigateBoundOrView(addDate(rawDate, v, "year", minDate, maxDate)); @@ -594,7 +583,11 @@ export const CalendarNav: React.FC = ({