Skip to content

Commit 4b42384

Browse files
feat(DatePicker): add accessibility announcement for arrow key navigation (#1033)
* feat(DatePicker): add accessibility announcement for arrow key navigation * chore: fix the tests * chore: add tests * chore: fix tests * chore: resolve comments * chore: handle trap * chore: fix tests * chore: add tests
1 parent cc4688f commit 4b42384

File tree

15 files changed

+1105
-23
lines changed

15 files changed

+1105
-23
lines changed

CLAUDE.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## About Octuple
6+
7+
Octuple is Eightfold's React Design System Component Library. It's a comprehensive collection of reusable React components, utilities, and hooks built with TypeScript and SCSS modules.
8+
9+
## Development Commands
10+
11+
### Primary Development Commands
12+
- `yarn storybook` - Run Storybook development server on port 2022
13+
- `yarn build` - Build the library for production (runs lint + rollup build)
14+
- `yarn test` - Run Jest unit tests with coverage
15+
- `yarn lint` - Run ESLint on all JS/JSX/TS/TSX files
16+
- `yarn typecheck` - Run TypeScript type checking without emitting files
17+
18+
### Testing Commands
19+
- `yarn test:update` - Update Jest snapshots
20+
- Run single test: `jest path/to/test.test.tsx`
21+
22+
### Build Commands
23+
- `yarn build-storybook` - Build Storybook for deployment
24+
- `yarn build:webpack` - Alternative webpack-based build (runs lint + webpack)
25+
26+
### Release Commands
27+
- `yarn release` - Standard version release (skips tests)
28+
- `yarn release:minor` - Minor version release
29+
- `yarn release:patch` - Patch version release
30+
- `yarn release:major` - Major version release
31+
32+
## Code Architecture
33+
34+
### Component Structure
35+
Components follow a strict modular structure in `src/components/`:
36+
- Each component has its own directory with TypeScript files, SCSS modules, Storybook stories, and Jest tests
37+
- Main export file: `src/octuple.ts` - exports all public components and utilities
38+
- Locale exports: `src/locale.ts` - internationalization utilities
39+
40+
### Key Directories
41+
- `src/components/` - All React components organized by component name
42+
- `src/hooks/` - Custom React hooks (useBoolean, useGestures, useMatchMedia, etc.)
43+
- `src/shared/` - Shared utilities and common components (FocusTrap, ResizeObserver, utilities)
44+
- `src/styles/` - Global SCSS styles and variables
45+
- `src/tests/` - Test utilities and setup files
46+
47+
### Component Patterns
48+
Components follow consistent patterns:
49+
- TypeScript interfaces defined in `ComponentName.types.ts`
50+
- SCSS modules using kebab-case class names (referenced as camelCase in JS)
51+
- Exported through barrel exports in `index.ts` files
52+
- Use `mergeClasses` utility for conditional class name handling
53+
- Support for themes via ConfigProvider context
54+
55+
### Build System
56+
- **Rollup** for library bundling (primary build system)
57+
- **Webpack** alternative build available
58+
- **SCSS modules** with camelCase conversion
59+
- **TypeScript** compilation with strict type checking
60+
- **PostCSS** for CSS processing and minification
61+
- Outputs both ESM (.mjs) and CommonJS (.js) formats
62+
63+
### Testing Approach
64+
- **Jest** with React Testing Library
65+
- **Enzyme** with React 17 adapter
66+
- **Snapshot testing** for component rendering
67+
- **MatchMedia mock** for responsive testing
68+
- **ResizeObserver** polyfill for tests
69+
- Coverage collection configured
70+
71+
### Component Guidelines
72+
Follow the established patterns in `src/components/COMPONENTS.md`:
73+
- Use functional components with TypeScript
74+
- Define props interfaces with JSDoc comments
75+
- Use SCSS modules for styling
76+
- Include Storybook stories for documentation
77+
- Write comprehensive Jest tests with snapshots
78+
- Export all public APIs through barrel exports
79+
80+
### Storybook
81+
- Development server runs on port 2022
82+
- Stories follow the pattern `ComponentName.stories.tsx`
83+
- Used for component documentation and visual testing
84+
85+
### Key Dependencies
86+
- React 17+ (peer dependency)
87+
- TypeScript for type safety
88+
- SCSS for styling with CSS modules
89+
- Storybook for component documentation
90+
- Jest + React Testing Library for testing
91+
- Various UI utility libraries (@floating-ui/react, react-spring, etc.)
92+
93+
### Conventional Commits
94+
Commit messages must follow the Conventional Commits specification:
95+
- Format: `<type>[optional scope]: <description>`
96+
- Types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test
97+
- Subject line max 100 characters
98+
- Combined body and footer max 100 characters

src/components/DateTimePicker/DatePicker/DatePicker.stories.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,110 @@ const Range_Status_Story: ComponentStory<typeof RangePicker> = (args) => {
557557
);
558558
};
559559

560+
const Accessibility_Announcement_Story: ComponentStory<typeof DatePicker> = (
561+
args
562+
) => {
563+
const onChange: DatePickerProps['onChange'] = (date, dateString) => {
564+
console.log(date, dateString);
565+
};
566+
567+
return (
568+
<ConfigProvider themeOptions={{ name: 'blue' }}>
569+
<Stack direction="vertical" flexGap="xl">
570+
<div>
571+
<h3>Default Announcement (uses locale text)</h3>
572+
<p>Opens with "Use arrow keys to navigate dates" announcement</p>
573+
<Stack direction="vertical" flexGap="m">
574+
<DatePicker
575+
{...args}
576+
onChange={onChange}
577+
announceArrowKeyNavigation
578+
placeholder="Click to open with announcement"
579+
/>
580+
<DatePicker.RangePicker
581+
onChange={(values, formatString) => {
582+
console.log(values, formatString);
583+
}}
584+
trapFocus
585+
announceArrowKeyNavigation
586+
/>
587+
</Stack>
588+
</div>
589+
590+
<div>
591+
<h3>Custom Announcement Message</h3>
592+
<p>Opens with custom announcement text</p>
593+
<DatePicker
594+
{...args}
595+
onChange={onChange}
596+
announceArrowKeyNavigation="Navigate this calendar using your arrow keys"
597+
placeholder="Click to open with custom announcement"
598+
/>
599+
</div>
600+
601+
<div>
602+
<h3>Focus Trap + Announcement (Coordinated)</h3>
603+
<p>
604+
Announces navigation first, then automatically moves focus to
605+
calendar after 1 second
606+
</p>
607+
<DatePicker
608+
{...args}
609+
onChange={onChange}
610+
announceArrowKeyNavigation={true}
611+
trapFocus={true}
612+
placeholder="Click for announcement → auto focus shift"
613+
/>
614+
</div>
615+
616+
<div>
617+
<h3>Focus Trap Only (Immediate)</h3>
618+
<p>Immediately moves focus to calendar without announcement</p>
619+
<DatePicker
620+
{...args}
621+
onChange={onChange}
622+
trapFocus={true}
623+
placeholder="Click for immediate focus shift"
624+
/>
625+
</div>
626+
627+
<div>
628+
<h3>No Announcement (default behavior)</h3>
629+
<p>Opens without any navigation announcement or focus changes</p>
630+
<DatePicker
631+
{...args}
632+
onChange={onChange}
633+
placeholder="Click to open without announcement"
634+
/>
635+
</div>
636+
637+
<div
638+
style={{
639+
backgroundColor: '#f5f5f5',
640+
padding: '16px',
641+
borderRadius: '4px',
642+
}}
643+
>
644+
<h4>Screen Reader Instructions:</h4>
645+
<p>
646+
To test this feature with a screen reader:
647+
<br />• Enable your screen reader (NVDA, JAWS, VoiceOver, etc.)
648+
<br /><strong>Coordinated example:</strong> Click "announcement →
649+
auto focus shift" - hear announcement, then focus moves to calendar
650+
after 1 second
651+
<br /><strong>Immediate example:</strong> Click "immediate focus
652+
shift" - focus moves to calendar immediately
653+
<br /><strong>Keyboard navigation:</strong> Use TAB/Shift+TAB to
654+
cycle within the trapped focus area
655+
<br /><strong>Exit:</strong> Press ESC or click outside to return
656+
focus to input and close picker
657+
</p>
658+
</div>
659+
</Stack>
660+
</ConfigProvider>
661+
);
662+
};
663+
560664
const Range_Picker_With_Aria_Labels_Story: ComponentStory<
561665
typeof RangePicker
562666
> = (args) => <DatePicker.RangePicker {...args} />;
@@ -592,6 +696,9 @@ export const Single_Borderless = Single_Borderless_Story.bind({});
592696
export const Range_Borderless = Range_Borderless_Story.bind({});
593697
export const Single_Status = Single_Status_Story.bind({});
594698
export const Range_Status = Range_Status_Story.bind({});
699+
export const Accessibility_Announcement = Accessibility_Announcement_Story.bind(
700+
{}
701+
);
595702
export const Range_Picker_With_Aria_Labels =
596703
Range_Picker_With_Aria_Labels_Story.bind({});
597704

@@ -622,6 +729,7 @@ export const __namedExportsOrder = [
622729
'Range_Borderless',
623730
'Single_Status',
624731
'Range_Status',
732+
'Accessibility_Announcement',
625733
'Range_Picker_With_Aria_Labels',
626734
];
627735

src/components/DateTimePicker/DatePicker/Generate/generateRangePicker.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export default function generateRangePicker<DateType>(
7878
todayButtonProps,
7979
todayActive = false,
8080
todayText: defaultTodayText,
81+
trapFocus = false,
8182
...rest
8283
} = props;
8384
const largeScreenActive: boolean = useMatchMedia(Breakpoints.Large);
@@ -268,6 +269,7 @@ export default function generateRangePicker<DateType>(
268269
superPrevIcon={IconName.mdiChevronDoubleLeft}
269270
superNextIcon={IconName.mdiChevronDoubleRight}
270271
allowClear
272+
trapFocus={trapFocus}
271273
{...rest}
272274
{...additionalOverrideProps}
273275
classNames={mergeClasses([

src/components/DateTimePicker/DatePicker/Styles/mixins.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,16 @@ $picker-input-padding-vertical: max(
118118
cursor: not-allowed;
119119
opacity: $disabled-alpha-value;
120120
}
121+
122+
// Screen reader only content mixin
123+
@mixin screen-reader-only() {
124+
position: absolute;
125+
width: 1px;
126+
height: 1px;
127+
padding: 0;
128+
margin: -1px;
129+
overflow: hidden;
130+
clip: rect(0, 0, 0, 0);
131+
white-space: nowrap;
132+
border: 0;
133+
}

src/components/DateTimePicker/Internal/Hooks/useCellProps.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { formatValue } from '../Utils/dateUtil';
22
import type { GenerateConfig } from '../Generate';
3-
import type { NullableDateType, Locale, RangeValue } from '../OcPicker.types';
3+
import type { NullableDateType, Locale } from '../OcPicker.types';
44

55
type UseCellPropsArgs<DateType> = {
66
generateConfig: GenerateConfig<DateType>;
@@ -11,7 +11,6 @@ type UseCellPropsArgs<DateType> = {
1111
) => boolean;
1212
today?: NullableDateType<DateType>;
1313
locale: Locale;
14-
rangedValue?: RangeValue<DateType>;
1514
};
1615

1716
export default function useCellProps<DateType>({
@@ -20,7 +19,6 @@ export default function useCellProps<DateType>({
2019
isSameCell,
2120
locale,
2221
generateConfig,
23-
rangedValue,
2422
}: UseCellPropsArgs<DateType>) {
2523
function getCellProps(currentDate: DateType) {
2624
return {
@@ -38,5 +36,5 @@ export default function useCellProps<DateType>({
3836
isCellFocused: isSameCell(value, currentDate),
3937
};
4038
}
41-
return rangedValue ? undefined : getCellProps;
39+
return getCellProps;
4240
}

src/components/DateTimePicker/Internal/Locale/en_US.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const locale: Locale = {
3232
nextAriaLabel: 'Next year',
3333
superPrevAriaLabel: 'Previous year',
3434
superNextAriaLabel: 'Next year',
35+
arrowKeyNavigationText: 'Use arrow keys to navigate the calendar',
3536
};
3637

3738
export default locale;

src/components/DateTimePicker/Internal/OcPicker.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type MergedOcPickerProps<DateType> = {
4444
function InnerPicker<DateType>(props: OcPickerProps<DateType>) {
4545
const {
4646
allowClear,
47+
announceArrowKeyNavigation,
4748
autoComplete = 'off',
4849
autoFocus,
4950
bordered = true,
@@ -370,6 +371,16 @@ function InnerPicker<DateType>(props: OcPickerProps<DateType>) {
370371
partialNode = partialRender(partialNode);
371372
}
372373

374+
const navigationAnnouncement = announceArrowKeyNavigation ? (
375+
<div
376+
className={styles.srOnly}
377+
aria-live="polite"
378+
aria-atomic="true"
379+
>
380+
{announceArrowKeyNavigation === true ? locale?.arrowKeyNavigationText : announceArrowKeyNavigation}
381+
</div>
382+
) : null;
383+
373384
const partial: JSX.Element = trapFocus ? (
374385
<FocusTrap
375386
data-testid="picker-dialog"
@@ -388,7 +399,10 @@ function InnerPicker<DateType>(props: OcPickerProps<DateType>) {
388399
}
389400
}}
390401
>
391-
{partialNode}
402+
<>
403+
{navigationAnnouncement}
404+
{partialNode}
405+
</>
392406
</FocusTrap>
393407
) : (
394408
<div
@@ -397,6 +411,7 @@ function InnerPicker<DateType>(props: OcPickerProps<DateType>) {
397411
e.preventDefault();
398412
}}
399413
>
414+
{navigationAnnouncement}
400415
{partialNode}
401416
</div>
402417
);

src/components/DateTimePicker/Internal/OcPicker.types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ export type Locale = {
149149
* The super next aria label.
150150
*/
151151
superNextAriaLabel?: string;
152+
/**
153+
* The arrow key navigation announcement text.
154+
*/
155+
arrowKeyNavigationText?: string;
152156
};
153157

154158
export type PartialMode =
@@ -621,6 +625,12 @@ export type OcPickerSharedProps<DateType> = {
621625
* @default false
622626
*/
623627
autoFocus?: boolean;
628+
/**
629+
* Announces arrow key navigation instructions when the picker opens.
630+
* When true, uses default locale text. When string, uses custom message.
631+
* @default false
632+
*/
633+
announceArrowKeyNavigation?: boolean | string;
624634
/**
625635
* Determines if the picker has a border style.
626636
*/

src/components/DateTimePicker/Internal/OcPickerPartial.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,6 @@ function OcPickerPartial<DateType>(props: OcPickerPartialProps<DateType>) {
238238
}
239239
return partialRef.current?.onKeyDown(e);
240240
}
241-
242241
return null;
243242
};
244243

0 commit comments

Comments
 (0)