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..c95558f49 100644 --- a/src/components/DateTimePicker/DatePicker/DatePicker.stories.tsx +++ b/src/components/DateTimePicker/DatePicker/DatePicker.stories.tsx @@ -557,6 +557,110 @@ 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

+ + + { + console.log(values, formatString); + }} + trapFocus + announceArrowKeyNavigation + /> + +
+ +
+

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 +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 Range_Picker_With_Aria_Labels = Range_Picker_With_Aria_Labels_Story.bind({}); @@ -622,6 +729,7 @@ export const __namedExportsOrder = [ 'Range_Borderless', 'Single_Status', 'Range_Status', + 'Accessibility_Announcement', 'Range_Picker_With_Aria_Labels', ]; 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/DatePicker/Styles/mixins.scss b/src/components/DateTimePicker/DatePicker/Styles/mixins.scss index 694cadac4..105c967e0 100644 --- a/src/components/DateTimePicker/DatePicker/Styles/mixins.scss +++ b/src/components/DateTimePicker/DatePicker/Styles/mixins.scss @@ -118,3 +118,16 @@ $picker-input-padding-vertical: max( cursor: not-allowed; opacity: $disabled-alpha-value; } + +// Screen reader only content mixin +@mixin screen-reader-only() { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} 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/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..23669630c 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, @@ -370,6 +371,16 @@ function InnerPicker(props: OcPickerProps) { partialNode = partialRender(partialNode); } + const navigationAnnouncement = announceArrowKeyNavigation ? ( +
+ {announceArrowKeyNavigation === true ? locale?.arrowKeyNavigationText : announceArrowKeyNavigation} +
+ ) : null; + const partial: JSX.Element = trapFocus ? ( (props: OcPickerProps) { } }} > - {partialNode} + <> + {navigationAnnouncement} + {partialNode} + ) : (
(props: OcPickerProps) { e.preventDefault(); }} > + {navigationAnnouncement} {partialNode}
); diff --git a/src/components/DateTimePicker/Internal/OcPicker.types.ts b/src/components/DateTimePicker/Internal/OcPicker.types.ts index 9b879d7f5..37b7cab8a 100644 --- a/src/components/DateTimePicker/Internal/OcPicker.types.ts +++ b/src/components/DateTimePicker/Internal/OcPicker.types.ts @@ -149,6 +149,10 @@ export type Locale = { * The super next aria label. */ superNextAriaLabel?: string; + /** + * The arrow key navigation announcement text. + */ + arrowKeyNavigationText?: string; }; export type PartialMode = @@ -621,6 +625,12 @@ export type OcPickerSharedProps = { * @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/OcPickerPartial.tsx b/src/components/DateTimePicker/Internal/OcPickerPartial.tsx index 22f334765..e1f2dc940 100644 --- a/src/components/DateTimePicker/Internal/OcPickerPartial.tsx +++ b/src/components/DateTimePicker/Internal/OcPickerPartial.tsx @@ -238,7 +238,6 @@ function OcPickerPartial(props: OcPickerPartialProps) { } return partialRef.current?.onKeyDown(e); } - return null; }; 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 ( 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..c49b31051 --- /dev/null +++ b/src/components/DateTimePicker/Internal/Tests/accessibility.test.tsx @@ -0,0 +1,322 @@ +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); + + // Should have announcement div with correct attributes + const announcementDiv = document.querySelector('[aria-live="polite"]') as HTMLElement; + expect(announcementDiv).toBeInTheDocument(); + expect(announcementDiv).toHaveAttribute('aria-live', 'polite'); + expect(announcementDiv).toHaveAttribute('aria-atomic', 'true'); + }); + + 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); + + // Should have announcement div with correct attributes + const announcementDiv = document.querySelector('[aria-live="polite"]') as HTMLElement; + expect(announcementDiv).toBeInTheDocument(); + expect(announcementDiv).toHaveAttribute('aria-live', 'polite'); + expect(announcementDiv).toHaveAttribute('aria-atomic', 'true'); + }); + + test('should handle timer cleanup after 1 second', () => { + 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"]') as HTMLElement; + expect(announcementDiv).toBeInTheDocument(); + + // Should not throw errors when timer executes + expect(() => { + act(() => { + jest.advanceTimersByTime(1000); + }); + }).not.toThrow(); + }); + + 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); + + // Should have announcement div + const announcementDiv = document.querySelector('[aria-live="polite"]') as HTMLElement; + expect(announcementDiv).toBeInTheDocument(); + expect(announcementDiv).toHaveAttribute('aria-live', 'polite'); + + // 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( + <> + +