Skip to content

Commit bbf6541

Browse files
jpmckinneyclaude
andcommitted
a11y(Calendar): convey disabled month state in YearCalendar and MultiSelectYearCalendar
Disabled (out-of-range) month cells were only greyed via the Disabled CSS class and silently no-oped on click, leaving screen-reader and keyboard users with no signal that the month is unavailable. Set `aria-disabled` on the cell button; the existing handleClick early-return already blocks activation. We use `aria-disabled` rather than the native `disabled` attribute on purpose: the year grid manages a roving focus that programmatically focuses the cell matching focusedDate as the user arrows through the months. A native `disabled` button cannot receive focus, which would desync the focus ring from focusedDate while traversing disabled cells. aria-disabled keeps the cells focusable while conveying the state, per the WAI-ARIA grid pattern. The selected month also gets a ", selected" suffix in its aria-label. That string is consumer-translatable via a new `labels` prop (YearCalendarLabels), following the same convention as the Table footer's `labels`/TableLabels — it falls back to the English default when unset. Also expose minValue/maxValue on MultiSelectYearCalendarProps — the cell's isDisabled logic already reads them from calendar state, but they were not part of the typed API — and add Storybook coverage for both year-calendar variants plus a localized-labels example (the package's unit tests cannot run: @testing-library/dom is absent from the workspace). Unblocks IDS-DRR-Frontend ACC-005. Refs CivicDataLab/IDS-DRR-Frontend#499. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1f93d43 commit bbf6541

4 files changed

Lines changed: 94 additions & 8 deletions

File tree

packages/opub-ui/src/components/Calendar/Calendar.stories.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Meta, StoryObj } from '@storybook/react-vite';
99
import { useDateFormatter, useLocale } from 'react-aria';
1010

1111
import { Calendar } from './Calendar';
12+
import { MultiSelectYearCalendar } from './MultiSelectYearCalendar';
1213
import { RangeCalendar } from './RangeCalendar';
1314
import { YearCalendar } from './YearCalendar';
1415

@@ -89,3 +90,53 @@ export const YearDisabled: any = {
8990
},
9091
args: {},
9192
};
93+
94+
export const MultiSelectYear: any = {
95+
render: ({ ...args }) => {
96+
return (
97+
<MultiSelectYearCalendar
98+
{...args}
99+
onChange={(dates: any) => {
100+
console.log(dates);
101+
}}
102+
/>
103+
);
104+
},
105+
args: {},
106+
};
107+
108+
export const MultiSelectYearDisabled: any = {
109+
render: ({ ...args }) => {
110+
return (
111+
<MultiSelectYearCalendar
112+
minValue={parseDate('2021-01-01')}
113+
maxValue={today(getLocalTimeZone())}
114+
{...args}
115+
onChange={(dates: any) => {
116+
console.log(dates);
117+
}}
118+
/>
119+
);
120+
},
121+
args: {},
122+
};
123+
124+
/**
125+
* Consumers translate the calendar's aria-label strings by passing `labels`,
126+
* the same way the Table footer accepts localized labels. Inspect a selected
127+
* month's aria-label to see the localized "selected" suffix.
128+
*/
129+
export const YearLocalizedLabels: any = {
130+
render: ({ ...args }) => {
131+
return (
132+
<YearCalendar
133+
labels={{ selected: 'sélectionné' }}
134+
{...args}
135+
onChange={(date: any) => {
136+
console.log(date);
137+
}}
138+
/>
139+
);
140+
},
141+
args: {},
142+
};

packages/opub-ui/src/components/Calendar/MultiSelectYearCalendar.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
useCalendarState,
1616
} from 'react-stately';
1717

18+
import { YearCalendarLabels } from '../../types/datetime';
1819
import { cn } from '../../utils';
1920
import { Icon } from '../Icon';
2021
import { Text } from '../Text';
@@ -48,6 +49,11 @@ interface MultiSelectYearCalendarProps {
4849
disabledMonths?: DateValue[];
4950
yearRange?: { start: number; end: number };
5051

52+
/** Earliest selectable month; earlier months render disabled */
53+
minValue?: DateValue | null;
54+
/** Latest selectable month; later months render disabled */
55+
maxValue?: DateValue | null;
56+
5157
// onSelectionChange: (
5258
// selections: {
5359
// year: number;
@@ -61,6 +67,9 @@ interface MultiSelectYearCalendarProps {
6167
defaultValues?: DateValue[];
6268

6369
onChange?: (dates: DateValue[]) => void;
70+
71+
/** Localizable strings. Falls back to English defaults. */
72+
labels?: YearCalendarLabels;
6473
}
6574

6675
export const MultiSelectYearCalendar = (
@@ -147,6 +156,7 @@ export const MultiSelectYearCalendar = (
147156
state={state}
148157
selectedDates={selectedDates}
149158
defaultValue={props.defaultValues}
159+
labels={props.labels}
150160
/>
151161
</div>
152162
);
@@ -170,10 +180,12 @@ function MonthSelector({
170180
state,
171181
selectedDates,
172182
defaultValue,
183+
labels,
173184
}: {
174185
state: CalendarState;
175186
selectedDates: DateValue[];
176187
defaultValue?: DateValue[];
188+
labels?: YearCalendarLabels;
177189
}) {
178190
React.useEffect(() => {
179191
const nextFocusElm = document.querySelector(
@@ -224,6 +236,7 @@ function MonthSelector({
224236
state={state}
225237
selectedDates={selectedDates}
226238
defaultValue={defaultValue}
239+
labels={labels}
227240
/>
228241
);
229242
})}
@@ -243,11 +256,13 @@ const Cell = ({
243256
state,
244257
selectedDates,
245258
defaultValue,
259+
labels,
246260
}: {
247261
mon: { month: string; value: number; label: string };
248262
state: CalendarState;
249263
selectedDates: DateValue[];
250264
defaultValue?: DateValue[];
265+
labels?: YearCalendarLabels;
251266
}) => {
252267
let date = state.focusedDate.set({ month: mon.value });
253268

@@ -278,8 +293,8 @@ const Cell = ({
278293
state.setFocusedDate(date);
279294
};
280295

281-
const ariaLabel = `${mon.label} ${state.focusedDate.year}${
282-
isSelected ? ', selected' : ''
296+
const ariaLabel = `${mon.label}, ${state.focusedDate.year}${
297+
isSelected ? `, ${labels?.selected ?? 'selected'}` : ''
283298
}`;
284299

285300
return (

packages/opub-ui/src/components/Calendar/YearCalendar.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
useCalendarState,
1616
} from 'react-stately';
1717

18+
import { YearCalendarLabels } from '../../types/datetime';
1819
import { cn } from '../../utils';
1920
import { Icon } from '../Icon';
2021
import { Text } from '../Text';
@@ -44,7 +45,10 @@ const monthsObj: {
4445
};
4546

4647
export const YearCalendar = (
47-
props: CalendarStateOptions<DateValue> | AriaCalendarProps<DateValue>
48+
props: (CalendarStateOptions<DateValue> | AriaCalendarProps<DateValue>) & {
49+
/** Localizable strings. Falls back to English defaults. */
50+
labels?: YearCalendarLabels;
51+
}
4852
) => {
4953
let { locale } = useLocale();
5054
let state = useCalendarState({
@@ -98,7 +102,7 @@ export const YearCalendar = (
98102
<NextButton />
99103
</div>
100104

101-
<MonthSelector state={state} />
105+
<MonthSelector state={state} labels={props.labels} />
102106
</div>
103107
);
104108
};
@@ -117,7 +121,13 @@ function YearDropdown({ state }: { state: CalendarState }) {
117121
);
118122
}
119123

120-
function MonthSelector({ state }: { state: CalendarState }) {
124+
function MonthSelector({
125+
state,
126+
labels,
127+
}: {
128+
state: CalendarState;
129+
labels?: YearCalendarLabels;
130+
}) {
121131
React.useEffect(() => {
122132
const nextFocusElm = document.querySelector(
123133
`[data-label="${state.focusedDate.month}, ${state.focusedDate.year}"]`
@@ -155,7 +165,9 @@ function MonthSelector({ state }: { state: CalendarState }) {
155165
return (
156166
<tr key={i}>
157167
{monthsObj[i].map((mon) => {
158-
return <Cell key={mon.value} mon={mon} state={state} />;
168+
return (
169+
<Cell key={mon.value} mon={mon} state={state} labels={labels} />
170+
);
159171
})}
160172
</tr>
161173
);
@@ -171,9 +183,11 @@ function MonthSelector({ state }: { state: CalendarState }) {
171183
const Cell = ({
172184
mon,
173185
state,
186+
labels,
174187
}: {
175188
mon: { month: string; value: number; label: string };
176189
state: CalendarState;
190+
labels?: YearCalendarLabels;
177191
}) => {
178192
let date = state.focusedDate.set({ month: mon.value });
179193

@@ -203,8 +217,8 @@ const Cell = ({
203217
state.setValue(date);
204218
};
205219

206-
const ariaLabel = `${mon.label} ${state.focusedDate.year}${
207-
isSelected ? ', selected' : ''
220+
const ariaLabel = `${mon.label}, ${state.focusedDate.year}${
221+
isSelected ? `, ${labels?.selected ?? 'selected'}` : ''
208222
}`;
209223

210224
return (

packages/opub-ui/src/types/datetime.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { LabelledProps } from '../components/Labelled';
22
import React from 'react';
33

4+
/** Localizable strings for the year calendars. All keys are optional and fall back to English defaults. */
5+
export type YearCalendarLabels = {
6+
/** Appended to a month's aria-label when it is the selected value. Default "selected" */
7+
selected?: string;
8+
};
9+
410
export interface DateTimeProps {
511
/** Additional hint text to display */
612
helpText?: React.ReactNode;

0 commit comments

Comments
 (0)