From bc2a423d03ef589b60f89696c69eae16df492ce2 Mon Sep 17 00:00:00 2001 From: Yash Raj Chhabra Date: Wed, 17 Sep 2025 18:02:38 +0530 Subject: [PATCH 1/8] feat(DatePicker): add accessibility announcement for arrow key navigation --- CLAUDE.md | 98 +++++++++ .../DatePicker/DatePicker.stories.tsx | 81 +++++++ .../DateTimePicker/Internal/Locale/en_US.ts | 1 + .../DateTimePicker/Internal/OcPicker.tsx | 49 ++++- .../DateTimePicker/Internal/OcPicker.types.ts | 10 + .../Tests/__snapshots__/picker.test.tsx.snap | 204 +++++++++++++++++- .../Internal/ocpicker.module.scss | 12 ++ 7 files changed, 451 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..9f35da8bc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## About Octuple + +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. + +## Development Commands + +### Primary Development Commands +- `yarn storybook` - Run Storybook development server on port 2022 +- `yarn build` - Build the library for production (runs lint + rollup build) +- `yarn test` - Run Jest unit tests with coverage +- `yarn lint` - Run ESLint on all JS/JSX/TS/TSX files +- `yarn typecheck` - Run TypeScript type checking without emitting files + +### Testing Commands +- `yarn test:update` - Update Jest snapshots +- Run single test: `jest path/to/test.test.tsx` + +### Build Commands +- `yarn build-storybook` - Build Storybook for deployment +- `yarn build:webpack` - Alternative webpack-based build (runs lint + webpack) + +### Release Commands +- `yarn release` - Standard version release (skips tests) +- `yarn release:minor` - Minor version release +- `yarn release:patch` - Patch version release +- `yarn release:major` - Major version release + +## Code Architecture + +### Component Structure +Components follow a strict modular structure in `src/components/`: +- Each component has its own directory with TypeScript files, SCSS modules, Storybook stories, and Jest tests +- Main export file: `src/octuple.ts` - exports all public components and utilities +- Locale exports: `src/locale.ts` - internationalization utilities + +### Key Directories +- `src/components/` - All React components organized by component name +- `src/hooks/` - Custom React hooks (useBoolean, useGestures, useMatchMedia, etc.) +- `src/shared/` - Shared utilities and common components (FocusTrap, ResizeObserver, utilities) +- `src/styles/` - Global SCSS styles and variables +- `src/tests/` - Test utilities and setup files + +### Component Patterns +Components follow consistent patterns: +- TypeScript interfaces defined in `ComponentName.types.ts` +- SCSS modules using kebab-case class names (referenced as camelCase in JS) +- Exported through barrel exports in `index.ts` files +- Use `mergeClasses` utility for conditional class name handling +- Support for themes via ConfigProvider context + +### Build System +- **Rollup** for library bundling (primary build system) +- **Webpack** alternative build available +- **SCSS modules** with camelCase conversion +- **TypeScript** compilation with strict type checking +- **PostCSS** for CSS processing and minification +- Outputs both ESM (.mjs) and CommonJS (.js) formats + +### Testing Approach +- **Jest** with React Testing Library +- **Enzyme** with React 17 adapter +- **Snapshot testing** for component rendering +- **MatchMedia mock** for responsive testing +- **ResizeObserver** polyfill for tests +- Coverage collection configured + +### Component Guidelines +Follow the established patterns in `src/components/COMPONENTS.md`: +- Use functional components with TypeScript +- Define props interfaces with JSDoc comments +- Use SCSS modules for styling +- Include Storybook stories for documentation +- Write comprehensive Jest tests with snapshots +- Export all public APIs through barrel exports + +### Storybook +- Development server runs on port 2022 +- Stories follow the pattern `ComponentName.stories.tsx` +- Used for component documentation and visual testing + +### Key Dependencies +- React 17+ (peer dependency) +- TypeScript for type safety +- SCSS for styling with CSS modules +- Storybook for component documentation +- Jest + React Testing Library for testing +- Various UI utility libraries (@floating-ui/react, react-spring, etc.) + +### Conventional Commits +Commit messages must follow the Conventional Commits specification: +- Format: `[optional scope]: ` +- Types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test +- Subject line max 100 characters +- Combined body and footer max 100 characters \ No newline at end of file diff --git a/src/components/DateTimePicker/DatePicker/DatePicker.stories.tsx b/src/components/DateTimePicker/DatePicker/DatePicker.stories.tsx index f0d0f8197..e25e2b6fc 100644 --- a/src/components/DateTimePicker/DatePicker/DatePicker.stories.tsx +++ b/src/components/DateTimePicker/DatePicker/DatePicker.stories.tsx @@ -557,6 +557,85 @@ const Range_Status_Story: ComponentStory = (args) => { ); }; +const Accessibility_Announcement_Story: ComponentStory = (args) => { + const onChange: DatePickerProps['onChange'] = (date, dateString) => { + console.log(date, dateString); + }; + + return ( + + +
+

Default Announcement (uses locale text)

+

Opens with "Use arrow keys to navigate dates" announcement

+ +
+ +
+

Custom Announcement Message

+

Opens with custom announcement text

+ +
+ +
+

Focus Trap + Announcement (Coordinated)

+

Announces navigation first, then automatically moves focus to calendar after 1 second

+ +
+ +
+

Focus Trap Only (Immediate)

+

Immediately moves focus to calendar without announcement

+ +
+ +
+

No Announcement (default behavior)

+

Opens without any navigation announcement or focus changes

+ +
+ +
+

Screen Reader Instructions:

+

+ To test this feature with a screen reader: +
• Enable your screen reader (NVDA, JAWS, VoiceOver, etc.) +
Coordinated example: Click "announcement → auto focus shift" - hear announcement, then focus moves to calendar after 1 second +
Immediate example: Click "immediate focus shift" - focus moves to calendar immediately +
Keyboard navigation: Use TAB/Shift+TAB to cycle within the trapped focus area +
Exit: Press ESC or click outside to return focus to input and close picker +

+
+
+
+ ); +}; + const Range_Picker_With_Aria_Labels_Story: ComponentStory< typeof RangePicker > = (args) => ; @@ -592,6 +671,7 @@ export const Single_Borderless = Single_Borderless_Story.bind({}); export const Range_Borderless = Range_Borderless_Story.bind({}); export const Single_Status = Single_Status_Story.bind({}); export const Range_Status = Range_Status_Story.bind({}); +export const Accessibility_Announcement = Accessibility_Announcement_Story.bind({}); export const Range_Picker_With_Aria_Labels = Range_Picker_With_Aria_Labels_Story.bind({}); @@ -622,6 +702,7 @@ export const __namedExportsOrder = [ 'Range_Borderless', 'Single_Status', 'Range_Status', + 'Accessibility_Announcement', 'Range_Picker_With_Aria_Labels', ]; diff --git a/src/components/DateTimePicker/Internal/Locale/en_US.ts b/src/components/DateTimePicker/Internal/Locale/en_US.ts index b970f3b10..08f67f228 100644 --- a/src/components/DateTimePicker/Internal/Locale/en_US.ts +++ b/src/components/DateTimePicker/Internal/Locale/en_US.ts @@ -32,6 +32,7 @@ const locale: Locale = { nextAriaLabel: 'Next year', superPrevAriaLabel: 'Previous year', superNextAriaLabel: 'Next year', + arrowKeyNavigationText: 'Use arrow keys to navigate the calendar', }; export default locale; diff --git a/src/components/DateTimePicker/Internal/OcPicker.tsx b/src/components/DateTimePicker/Internal/OcPicker.tsx index 22b3d141f..4f2966f65 100644 --- a/src/components/DateTimePicker/Internal/OcPicker.tsx +++ b/src/components/DateTimePicker/Internal/OcPicker.tsx @@ -44,6 +44,7 @@ type MergedOcPickerProps = { function InnerPicker(props: OcPickerProps) { const { allowClear, + announceArrowKeyNavigation, autoComplete = 'off', autoFocus, bordered = true, @@ -122,6 +123,8 @@ function InnerPicker(props: OcPickerProps) { useRef(null); const containerRef: React.MutableRefObject = useRef(null); + const announcementRef: React.MutableRefObject = + useRef(null); // Real value const [mergedValue, setInnerValue] = useMergedState(null, { @@ -275,6 +278,42 @@ function InnerPicker(props: OcPickerProps) { changeOnBlur, }); + // Announce arrow key navigation and coordinate with focus trap when picker opens + useEffect(() => { + if (mergedOpen) { + // Handle accessibility announcement + if (announceArrowKeyNavigation && announcementRef.current) { + const message = announceArrowKeyNavigation ?? locale?.arrowKeyNavigationText; + + announcementRef.current.textContent = message as string; + + // Clear announcement and activate focus trap after announcement completes + const timer = setTimeout(() => { + if (announcementRef.current) { + announcementRef.current.textContent = ''; + } + // Activate focus trap after announcement is complete + if (trapFocus) { + setTrap(true); + } + }, 2000); + + return () => clearTimeout(timer); + } + // If trapFocus is enabled but no announcement, activate immediately + else if (trapFocus) { + setTrap(true); + } + } else { + // Reset trap when picker closes + if (trapFocus) { + setTrap(false); + } + } + + return undefined; + }, [mergedOpen, announceArrowKeyNavigation, locale?.arrowKeyNavigationText, trapFocus, setTrap]); + // Close should sync back with text value useEffect((): void => { if (!mergedOpen) { @@ -388,7 +427,15 @@ function InnerPicker(props: OcPickerProps) { } }} > - {partialNode} + <> +
+ {partialNode} + ) : (
= { * @default false */ autoFocus?: boolean; + /** + * Announces arrow key navigation instructions when the picker opens. + * When true, uses default locale text. When string, uses custom message. + * @default false + */ + announceArrowKeyNavigation?: boolean | string; /** * Determines if the picker has a border style. */ diff --git a/src/components/DateTimePicker/Internal/Tests/__snapshots__/picker.test.tsx.snap b/src/components/DateTimePicker/Internal/Tests/__snapshots__/picker.test.tsx.snap index 0171e64c8..1c5798dd7 100644 --- a/src/components/DateTimePicker/Internal/Tests/__snapshots__/picker.test.tsx.snap +++ b/src/components/DateTimePicker/Internal/Tests/__snapshots__/picker.test.tsx.snap @@ -712,6 +712,49 @@ LoadedCheerio { "role": "dialog", }, "children": Array [ + Node { + "attribs": Object { + "aria-atomic": "true", + "aria-live": "polite", + "class": "sr-only", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "Light", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "h1", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + }, Node { "attribs": Object {}, "children": Array [ @@ -727,7 +770,30 @@ LoadedCheerio { "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": [Circular], - "prev": null, + "prev": Node { + "attribs": Object { + "aria-atomic": "true", + "aria-live": "polite", + "class": "sr-only", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + }, "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, @@ -811,6 +877,49 @@ LoadedCheerio { "role": "dialog", }, "children": Array [ + Node { + "attribs": Object { + "aria-atomic": "true", + "aria-live": "polite", + "class": "sr-only", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "Light", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "h1", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + }, Node { "attribs": Object {}, "children": Array [ @@ -826,7 +935,30 @@ LoadedCheerio { "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": [Circular], - "prev": null, + "prev": Node { + "attribs": Object { + "aria-atomic": "true", + "aria-live": "polite", + "class": "sr-only", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + }, "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, @@ -913,6 +1045,49 @@ LoadedCheerio { "role": "dialog", }, "children": Array [ + Node { + "attribs": Object { + "aria-atomic": "true", + "aria-live": "polite", + "class": "sr-only", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": Node { + "attribs": Object {}, + "children": Array [ + Node { + "data": "Light", + "next": null, + "parent": [Circular], + "prev": null, + "type": "text", + }, + ], + "name": "h1", + "namespace": "http://www.w3.org/1999/xhtml", + "next": null, + "parent": [Circular], + "prev": [Circular], + "type": "tag", + "x-attribsNamespace": Object {}, + "x-attribsPrefix": Object {}, + }, + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + }, Node { "attribs": Object {}, "children": Array [ @@ -928,7 +1103,30 @@ LoadedCheerio { "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": [Circular], - "prev": null, + "prev": Node { + "attribs": Object { + "aria-atomic": "true", + "aria-live": "polite", + "class": "sr-only", + }, + "children": Array [], + "name": "div", + "namespace": "http://www.w3.org/1999/xhtml", + "next": [Circular], + "parent": [Circular], + "prev": null, + "type": "tag", + "x-attribsNamespace": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + "x-attribsPrefix": Object { + "aria-atomic": undefined, + "aria-live": undefined, + "class": undefined, + }, + }, "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, diff --git a/src/components/DateTimePicker/Internal/ocpicker.module.scss b/src/components/DateTimePicker/Internal/ocpicker.module.scss index efb14ac58..341a121ad 100644 --- a/src/components/DateTimePicker/Internal/ocpicker.module.scss +++ b/src/components/DateTimePicker/Internal/ocpicker.module.scss @@ -1,5 +1,17 @@ @import '../DatePicker/Styles/mixins'; +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .picker { border: var(--picker-border); border-radius: $picker-border-radius; From 02365af70fd0a7082db3ca4cbe5b1f9659329f41 Mon Sep 17 00:00:00 2001 From: Yash Raj Chhabra Date: Wed, 17 Sep 2025 23:12:19 +0530 Subject: [PATCH 2/8] chore: fix the tests --- .../DateTimePicker/Internal/OcPicker.tsx | 35 +-- .../Tests/__snapshots__/picker.test.tsx.snap | 204 +----------------- 2 files changed, 22 insertions(+), 217 deletions(-) diff --git a/src/components/DateTimePicker/Internal/OcPicker.tsx b/src/components/DateTimePicker/Internal/OcPicker.tsx index 4f2966f65..1f45db57e 100644 --- a/src/components/DateTimePicker/Internal/OcPicker.tsx +++ b/src/components/DateTimePicker/Internal/OcPicker.tsx @@ -280,32 +280,33 @@ function InnerPicker(props: OcPickerProps) { // Announce arrow key navigation and coordinate with focus trap when picker opens useEffect(() => { + // Only run this effect if announceArrowKeyNavigation is enabled + if (!announceArrowKeyNavigation) { + return undefined; + } + if (mergedOpen) { // Handle accessibility announcement if (announceArrowKeyNavigation && announcementRef.current) { - const message = announceArrowKeyNavigation ?? locale?.arrowKeyNavigationText; + const message = announceArrowKeyNavigation === true ? locale?.arrowKeyNavigationText : announceArrowKeyNavigation; announcementRef.current.textContent = message as string; - // Clear announcement and activate focus trap after announcement completes + // Clear announcement and optionally activate focus trap after announcement completes const timer = setTimeout(() => { if (announcementRef.current) { announcementRef.current.textContent = ''; } - // Activate focus trap after announcement is complete - if (trapFocus) { + // Only activate focus trap if both trapFocus is enabled AND announcement is being used + if (trapFocus && announceArrowKeyNavigation) { setTrap(true); } - }, 2000); + }, 1000); return () => clearTimeout(timer); } - // If trapFocus is enabled but no announcement, activate immediately - else if (trapFocus) { - setTrap(true); - } } else { - // Reset trap when picker closes + // Reset trap when picker closes (only if trapFocus was actually being used) if (trapFocus) { setTrap(false); } @@ -428,12 +429,14 @@ function InnerPicker(props: OcPickerProps) { }} > <> -
+ {announceArrowKeyNavigation && ( +
+ )} {partialNode} diff --git a/src/components/DateTimePicker/Internal/Tests/__snapshots__/picker.test.tsx.snap b/src/components/DateTimePicker/Internal/Tests/__snapshots__/picker.test.tsx.snap index 1c5798dd7..0171e64c8 100644 --- a/src/components/DateTimePicker/Internal/Tests/__snapshots__/picker.test.tsx.snap +++ b/src/components/DateTimePicker/Internal/Tests/__snapshots__/picker.test.tsx.snap @@ -712,49 +712,6 @@ LoadedCheerio { "role": "dialog", }, "children": Array [ - Node { - "attribs": Object { - "aria-atomic": "true", - "aria-live": "polite", - "class": "sr-only", - }, - "children": Array [], - "name": "div", - "namespace": "http://www.w3.org/1999/xhtml", - "next": Node { - "attribs": Object {}, - "children": Array [ - Node { - "data": "Light", - "next": null, - "parent": [Circular], - "prev": null, - "type": "text", - }, - ], - "name": "h1", - "namespace": "http://www.w3.org/1999/xhtml", - "next": null, - "parent": [Circular], - "prev": [Circular], - "type": "tag", - "x-attribsNamespace": Object {}, - "x-attribsPrefix": Object {}, - }, - "parent": [Circular], - "prev": null, - "type": "tag", - "x-attribsNamespace": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - "x-attribsPrefix": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - }, Node { "attribs": Object {}, "children": Array [ @@ -770,30 +727,7 @@ LoadedCheerio { "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": [Circular], - "prev": Node { - "attribs": Object { - "aria-atomic": "true", - "aria-live": "polite", - "class": "sr-only", - }, - "children": Array [], - "name": "div", - "namespace": "http://www.w3.org/1999/xhtml", - "next": [Circular], - "parent": [Circular], - "prev": null, - "type": "tag", - "x-attribsNamespace": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - "x-attribsPrefix": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - }, + "prev": null, "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, @@ -877,49 +811,6 @@ LoadedCheerio { "role": "dialog", }, "children": Array [ - Node { - "attribs": Object { - "aria-atomic": "true", - "aria-live": "polite", - "class": "sr-only", - }, - "children": Array [], - "name": "div", - "namespace": "http://www.w3.org/1999/xhtml", - "next": Node { - "attribs": Object {}, - "children": Array [ - Node { - "data": "Light", - "next": null, - "parent": [Circular], - "prev": null, - "type": "text", - }, - ], - "name": "h1", - "namespace": "http://www.w3.org/1999/xhtml", - "next": null, - "parent": [Circular], - "prev": [Circular], - "type": "tag", - "x-attribsNamespace": Object {}, - "x-attribsPrefix": Object {}, - }, - "parent": [Circular], - "prev": null, - "type": "tag", - "x-attribsNamespace": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - "x-attribsPrefix": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - }, Node { "attribs": Object {}, "children": Array [ @@ -935,30 +826,7 @@ LoadedCheerio { "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": [Circular], - "prev": Node { - "attribs": Object { - "aria-atomic": "true", - "aria-live": "polite", - "class": "sr-only", - }, - "children": Array [], - "name": "div", - "namespace": "http://www.w3.org/1999/xhtml", - "next": [Circular], - "parent": [Circular], - "prev": null, - "type": "tag", - "x-attribsNamespace": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - "x-attribsPrefix": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - }, + "prev": null, "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, @@ -1045,49 +913,6 @@ LoadedCheerio { "role": "dialog", }, "children": Array [ - Node { - "attribs": Object { - "aria-atomic": "true", - "aria-live": "polite", - "class": "sr-only", - }, - "children": Array [], - "name": "div", - "namespace": "http://www.w3.org/1999/xhtml", - "next": Node { - "attribs": Object {}, - "children": Array [ - Node { - "data": "Light", - "next": null, - "parent": [Circular], - "prev": null, - "type": "text", - }, - ], - "name": "h1", - "namespace": "http://www.w3.org/1999/xhtml", - "next": null, - "parent": [Circular], - "prev": [Circular], - "type": "tag", - "x-attribsNamespace": Object {}, - "x-attribsPrefix": Object {}, - }, - "parent": [Circular], - "prev": null, - "type": "tag", - "x-attribsNamespace": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - "x-attribsPrefix": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - }, Node { "attribs": Object {}, "children": Array [ @@ -1103,30 +928,7 @@ LoadedCheerio { "namespace": "http://www.w3.org/1999/xhtml", "next": null, "parent": [Circular], - "prev": Node { - "attribs": Object { - "aria-atomic": "true", - "aria-live": "polite", - "class": "sr-only", - }, - "children": Array [], - "name": "div", - "namespace": "http://www.w3.org/1999/xhtml", - "next": [Circular], - "parent": [Circular], - "prev": null, - "type": "tag", - "x-attribsNamespace": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - "x-attribsPrefix": Object { - "aria-atomic": undefined, - "aria-live": undefined, - "class": undefined, - }, - }, + "prev": null, "type": "tag", "x-attribsNamespace": Object {}, "x-attribsPrefix": Object {}, From 6ef73c0a4edf9ae3a9d6b50b81c6d015fd990d10 Mon Sep 17 00:00:00 2001 From: Yash Raj Chhabra Date: Wed, 17 Sep 2025 23:18:18 +0530 Subject: [PATCH 3/8] chore: add tests --- .../Internal/Tests/accessibility.test.tsx | 336 ++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 src/components/DateTimePicker/Internal/Tests/accessibility.test.tsx diff --git a/src/components/DateTimePicker/Internal/Tests/accessibility.test.tsx b/src/components/DateTimePicker/Internal/Tests/accessibility.test.tsx new file mode 100644 index 000000000..f6c12b8c5 --- /dev/null +++ b/src/components/DateTimePicker/Internal/Tests/accessibility.test.tsx @@ -0,0 +1,336 @@ +import React from 'react'; +import Enzyme from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import { render, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { DayjsPicker } from './util/commonUtil'; + +Enzyme.configure({ adapter: new Adapter() }); + +describe('DatePicker Accessibility Announcements', () => { + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + }); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + // Clean up any announcement divs + const announcements = document.querySelectorAll('[aria-live="polite"]'); + announcements.forEach(el => el.remove()); + }); + + describe('announceArrowKeyNavigation prop', () => { + test('should not render announcement div when announceArrowKeyNavigation is false/undefined', () => { + const { container } = render(); + + // Open the picker + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should not have any announcement div + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeNull(); + }); + + test('should render announcement div when announceArrowKeyNavigation is true', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should have announcement div (look in document since popup is in Portal) + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeInTheDocument(); + expect(announcementDiv).toHaveAttribute('aria-atomic', 'true'); + expect(announcementDiv).toHaveClass('sr-only'); + }); + + test('should render announcement div when announceArrowKeyNavigation is a custom string', () => { + const customMessage = 'Navigate using arrow keys for better accessibility'; + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should have announcement div + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeInTheDocument(); + }); + }); + + describe('announcement content and timing', () => { + test('should announce default locale text when announceArrowKeyNavigation is true', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Wait for useEffect to run + act(() => { + jest.runOnlyPendingTimers(); + }); + + const announcementDiv = document.querySelector('[aria-live="polite"]') as HTMLElement; + expect(announcementDiv.textContent).toBe('Use arrow keys to navigate the calendar'); + }); + + test('should announce custom message when announceArrowKeyNavigation is a string', () => { + const customMessage = 'Custom navigation instructions for screen readers'; + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Wait for useEffect to run + act(() => { + jest.runOnlyPendingTimers(); + }); + + const announcementDiv = document.querySelector('[aria-live="polite"]') as HTMLElement; + expect(announcementDiv.textContent).toBe(customMessage); + }); + + test('should clear announcement text after 1 second', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Wait for useEffect to run + act(() => { + jest.runOnlyPendingTimers(); + }); + + const announcementDiv = document.querySelector('[aria-live="polite"]') as HTMLElement; + + // Initially should have the announcement text + expect(announcementDiv.textContent).toBe('Use arrow keys to navigate the calendar'); + + // After 1 second, should be cleared + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(announcementDiv.textContent).toBe(''); + }); + + test('should not announce when picker is closed', () => { + const { container } = render( + + ); + + // Don't open the picker + const announcementDiv = document.querySelector('[aria-live="polite"]'); + + // Should not have announcement div since picker isn't open + expect(announcementDiv).toBeNull(); + }); + }); + + describe('integration with focus trap', () => { + test('should work with focus trap enabled', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Wait for useEffect to run + act(() => { + jest.runOnlyPendingTimers(); + }); + + // Should have announcement initially + const announcementDiv = document.querySelector('[aria-live="polite"]') as HTMLElement; + expect(announcementDiv.textContent).toBe('Use arrow keys to navigate the calendar'); + + // Focus trap container should be present + const focusTrapContainer = document.querySelector('[data-testid="picker-dialog"]'); + expect(focusTrapContainer).toBeInTheDocument(); + }); + + test('should work when only trapFocus is enabled without announcement', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should not have announcement div + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeNull(); + + // Focus trap should still work + const focusTrapContainer = document.querySelector('[data-testid="picker-dialog"]'); + expect(focusTrapContainer).toBeInTheDocument(); + }); + }); + + describe('cleanup and edge cases', () => { + test('should cleanup timer when component unmounts', () => { + const { container, unmount } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Unmount before timer completes + unmount(); + + // Should not throw any errors when timer tries to execute + expect(() => { + act(() => { + jest.advanceTimersByTime(1000); + }); + }).not.toThrow(); + }); + + test('should handle rapid open/close cycles gracefully', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + + // Rapidly open and close picker multiple times + for (let i = 0; i < 3; i++) { + fireEvent.mouseDown(input); + fireEvent.click(input); + fireEvent.keyDown(input, { key: 'Escape' }); + } + + // Should not throw errors when timers execute + expect(() => { + act(() => { + jest.advanceTimersByTime(1000); + }); + }).not.toThrow(); + }); + }); + + describe('accessibility attributes', () => { + test('should have correct ARIA attributes on announcement div', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toHaveAttribute('aria-live', 'polite'); + expect(announcementDiv).toHaveAttribute('aria-atomic', 'true'); + expect(announcementDiv).toHaveClass('sr-only'); + }); + }); + + describe('integration with existing DatePicker functionality', () => { + test('should not interfere with normal picker operation', () => { + const onChange = jest.fn(); + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Select a date + const dateCell = document.querySelector('.picker-cell-inner'); + if (dateCell) { + fireEvent.click(dateCell); + } + + // Should trigger onChange as normal + expect(onChange).toHaveBeenCalled(); + }); + + test('should not interfere with keyboard navigation', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should be able to navigate with arrow keys without errors + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'ArrowRight' }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(input).toBeInTheDocument(); + }); + + test('should not interfere with changeOnBlur functionality', () => { + const onChange = jest.fn(); + const { container } = render( + <> + +

Focus Trap + Announcement (Coordinated)

-

Announces navigation first, then automatically moves focus to calendar after 1 second

+

+ Announces navigation first, then automatically moves focus to + calendar after 1 second +

= (arg />
-
+

Screen Reader Instructions:

To test this feature with a screen reader:
• Enable your screen reader (NVDA, JAWS, VoiceOver, etc.) -
Coordinated example: Click "announcement → auto focus shift" - hear announcement, then focus moves to calendar after 1 second -
Immediate example: Click "immediate focus shift" - focus moves to calendar immediately -
Keyboard navigation: Use TAB/Shift+TAB to cycle within the trapped focus area -
Exit: Press ESC or click outside to return focus to input and close picker +
Coordinated example: Click "announcement → + auto focus shift" - hear announcement, then focus moves to calendar + after 1 second +
Immediate example: Click "immediate focus + shift" - focus moves to calendar immediately +
Keyboard navigation: Use TAB/Shift+TAB to + cycle within the trapped focus area +
Exit: Press ESC or click outside to return + focus to input and close picker

@@ -681,7 +696,9 @@ export const Single_Borderless = Single_Borderless_Story.bind({}); export const Range_Borderless = Range_Borderless_Story.bind({}); export const Single_Status = Single_Status_Story.bind({}); export const Range_Status = Range_Status_Story.bind({}); -export const Accessibility_Announcement = Accessibility_Announcement_Story.bind({}); +export const Accessibility_Announcement = Accessibility_Announcement_Story.bind( + {} +); export const Range_Picker_With_Aria_Labels = Range_Picker_With_Aria_Labels_Story.bind({}); diff --git a/src/components/DateTimePicker/DatePicker/Generate/generateRangePicker.tsx b/src/components/DateTimePicker/DatePicker/Generate/generateRangePicker.tsx index bed114b88..ec98bfd70 100644 --- a/src/components/DateTimePicker/DatePicker/Generate/generateRangePicker.tsx +++ b/src/components/DateTimePicker/DatePicker/Generate/generateRangePicker.tsx @@ -78,6 +78,7 @@ export default function generateRangePicker( todayButtonProps, todayActive = false, todayText: defaultTodayText, + trapFocus = false, ...rest } = props; const largeScreenActive: boolean = useMatchMedia(Breakpoints.Large); @@ -268,6 +269,7 @@ export default function generateRangePicker( superPrevIcon={IconName.mdiChevronDoubleLeft} superNextIcon={IconName.mdiChevronDoubleRight} allowClear + trapFocus={trapFocus} {...rest} {...additionalOverrideProps} classNames={mergeClasses([ diff --git a/src/components/DateTimePicker/Internal/Hooks/useCellProps.ts b/src/components/DateTimePicker/Internal/Hooks/useCellProps.ts index cf3466a37..d64c6b36b 100644 --- a/src/components/DateTimePicker/Internal/Hooks/useCellProps.ts +++ b/src/components/DateTimePicker/Internal/Hooks/useCellProps.ts @@ -1,6 +1,6 @@ import { formatValue } from '../Utils/dateUtil'; import type { GenerateConfig } from '../Generate'; -import type { NullableDateType, Locale, RangeValue } from '../OcPicker.types'; +import type { NullableDateType, Locale } from '../OcPicker.types'; type UseCellPropsArgs = { generateConfig: GenerateConfig; @@ -11,7 +11,6 @@ type UseCellPropsArgs = { ) => boolean; today?: NullableDateType; locale: Locale; - rangedValue?: RangeValue; }; export default function useCellProps({ @@ -20,7 +19,6 @@ export default function useCellProps({ isSameCell, locale, generateConfig, - rangedValue, }: UseCellPropsArgs) { function getCellProps(currentDate: DateType) { return { @@ -38,5 +36,5 @@ export default function useCellProps({ isCellFocused: isSameCell(value, currentDate), }; } - return rangedValue ? undefined : getCellProps; + return getCellProps; } diff --git a/src/components/DateTimePicker/Internal/OcRangePicker.tsx b/src/components/DateTimePicker/Internal/OcRangePicker.tsx index bbc909226..ab00f2675 100644 --- a/src/components/DateTimePicker/Internal/OcRangePicker.tsx +++ b/src/components/DateTimePicker/Internal/OcRangePicker.tsx @@ -5,6 +5,7 @@ import { mergeClasses, requestAnimationFrameWrapper, } from '../../../shared/utilities'; +import { FocusTrap } from '../../../shared/FocusTrap'; import { useMergedState } from '../../../hooks/useMergedState'; import type { EventValue, @@ -121,6 +122,7 @@ function InnerRangePicker(props: OcRangePickerProps) { activePickerIndex, allowClear, allowEmpty, + announceArrowKeyNavigation, autoComplete = 'off', autoFocus, bordered = true, @@ -188,6 +190,7 @@ function InnerRangePicker(props: OcRangePickerProps) { todayButtonProps, todayActive, todayText, + trapFocus = false, value, startDateInputAriaLabel = '', endDateInputAriaLabel = '', @@ -667,15 +670,18 @@ function InnerRangePicker(props: OcRangePickerProps) { onKeyDown?.(e, preventDefault); }, changeOnBlur, + trapFocus, }; - const [startInputProps, { focused: startFocused, typing: startTyping }] = - usePickerInput({ - ...getSharedInputHookProps(0, resetStartText), - open: startOpen, - value: startText, - ...sharedPickerInput, - }); + const [ + startInputProps, + { focused: startFocused, typing: startTyping, trap, setTrap }, + ] = usePickerInput({ + ...getSharedInputHookProps(0, resetStartText), + open: startOpen, + value: startText, + ...sharedPickerInput, + }); const [endInputProps, { focused: endFocused, typing: endTyping }] = usePickerInput({ @@ -831,15 +837,17 @@ function InnerRangePicker(props: OcRangePickerProps) { }); } - return ( - + const navigationAnnouncement = announceArrowKeyNavigation ? ( +
+ {announceArrowKeyNavigation === true + ? locale?.arrowKeyNavigationText + : announceArrowKeyNavigation} +
+ ) : null; + + const partialContent = ( + <> + {navigationAnnouncement} {...(props as any)} {...partialProps} @@ -917,6 +925,42 @@ function InnerRangePicker(props: OcRangePickerProps) { } size={size} /> + + ); + + const partial: JSX.Element = trapFocus ? ( + { + e.preventDefault(); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + triggerOpen(false, mergedActivePickerIndex, 'cancel'); + setTrap(false); + } + }} + > + {partialContent} + + ) : ( + partialContent + ); + + return ( + + {partial} ); } @@ -1229,11 +1273,13 @@ function InnerRangePicker(props: OcRangePickerProps) { value={{ operationRef, hideHeader: picker === 'time', + partialRef: partialDivRef, onDateMouseEnter, onDateMouseLeave, hideRanges: true, onSelect: onContextSelect, open: mergedOpen, + trapFocus: trapFocus && trap, }} > (props: DateBodyProps) { isSameCell: (current, target) => isSameDate(generateConfig, current, target) && trapFocus, locale, - rangedValue: rangedValue, }); return ( From c54fe3f5dc29dd9854e996359f0d049d0c6fa05a Mon Sep 17 00:00:00 2001 From: Yash Raj Chhabra Date: Fri, 19 Sep 2025 18:25:11 +0530 Subject: [PATCH 8/8] chore: add tests --- .../Internal/Tests/range.test.tsx | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/src/components/DateTimePicker/Internal/Tests/range.test.tsx b/src/components/DateTimePicker/Internal/Tests/range.test.tsx index 13644580d..49eaacabd 100644 --- a/src/components/DateTimePicker/Internal/Tests/range.test.tsx +++ b/src/components/DateTimePicker/Internal/Tests/range.test.tsx @@ -21,6 +21,7 @@ import enUS from '../Locale/en_US'; import type { OcPickerMode } from '../OcPicker.types'; import { ButtonVariant } from '../../../Button'; import { createEvent, fireEvent, render } from '@testing-library/react'; +import '@testing-library/jest-dom'; Enzyme.configure({ adapter: new Adapter() }); @@ -2036,4 +2037,154 @@ describe('Picker.Range', () => { expect(onChange.mock.calls[0][0][0].format('HH:mm:ss')).toBe('00:00:00'); expect(onChange.mock.calls[0][0][1].format('HH:mm:ss')).toBe('23:59:59'); }); + + describe('accessibility features', () => { + describe('announceArrowKeyNavigation prop', () => { + it('should not render announcement div when announceArrowKeyNavigation is false/undefined', () => { + const { container } = render(); + + // Open the picker + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should not have any announcement div + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeNull(); + }); + + it('should render announcement div when announceArrowKeyNavigation is true', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should have announcement div (look in document since popup is in Portal) + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeInTheDocument(); + expect(announcementDiv).toHaveAttribute('aria-atomic', 'true'); + expect(announcementDiv).toHaveClass('sr-only'); + }); + + it('should render announcement div when announceArrowKeyNavigation is a custom string', () => { + const customMessage = + 'Navigate using arrow keys for better accessibility'; + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should have announcement div with custom message + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeInTheDocument(); + expect(announcementDiv).toHaveTextContent(customMessage); + }); + }); + + describe('trapFocus prop', () => { + it('should not render FocusTrap when trapFocus is false (default)', () => { + const { container } = render(); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should not have focus trap container + const focusTrapContainer = document.querySelector( + '[data-testid="picker-dialog"]' + ); + expect(focusTrapContainer).toBeNull(); + }); + + it('should render FocusTrap when trapFocus is true', () => { + const { container } = render(); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should have focus trap container + const focusTrapContainer = document.querySelector( + '[data-testid="picker-dialog"]' + ); + expect(focusTrapContainer).toBeInTheDocument(); + expect(focusTrapContainer).toHaveAttribute('role', 'dialog'); + expect(focusTrapContainer).toHaveAttribute('aria-modal', 'true'); + }); + + it('should handle Escape key when trapFocus is enabled', () => { + const onOpenChange = jest.fn(); + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Focus trap should be present + const focusTrapContainer = document.querySelector( + '[data-testid="picker-dialog"]' + ); + expect(focusTrapContainer).toBeInTheDocument(); + + // Press Escape key + fireEvent.keyDown(focusTrapContainer!, { key: 'Escape' }); + + // Should trigger onOpenChange with cancel + expect(onOpenChange).toHaveBeenCalled(); + }); + }); + + describe('basic integration tests', () => { + it('should work with both trapFocus and announceArrowKeyNavigation enabled', () => { + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + // Should have both focus trap and announcement + const focusTrapContainer = document.querySelector( + '[data-testid="picker-dialog"]' + ); + expect(focusTrapContainer).toBeInTheDocument(); + + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeInTheDocument(); + }); + + it('should have two input fields for range picker', () => { + const { container } = render(); + const inputs = container.querySelectorAll('input'); + expect(inputs).toHaveLength(2); + }); + + it('should support custom announcement messages', () => { + const customMessage = 'Use arrow keys to navigate range calendar'; + const { container } = render( + + ); + + const input = container.querySelector('input')!; + fireEvent.mouseDown(input); + fireEvent.click(input); + + const announcementDiv = document.querySelector('[aria-live="polite"]'); + expect(announcementDiv).toBeInTheDocument(); + expect(announcementDiv).toHaveTextContent(customMessage); + }); + }); + }); });