From 1378dcd2e23e46ae65f4287082920e09b984b5d8 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 6 Aug 2026 17:56:04 +0200 Subject: [PATCH 1/8] feat(react-headless-components-preview): add swatch picker controls --- ...-931596e2-bc46-4837-a7cc-db12b9edd5cd.json | 7 + .../bundle-size/AllComponents.fixture.js | 2 + .../library/etc/swatch-picker.api.md | 120 ++++++++++ .../library/package.json | 7 + .../ColorSwatch/ColorSwatch.test.tsx | 26 +++ .../SwatchPicker/ColorSwatch/ColorSwatch.tsx | 14 ++ .../ColorSwatch/ColorSwatch.types.ts | 5 + .../SwatchPicker/ColorSwatch/index.ts | 4 + .../ColorSwatch/renderColorSwatch.ts | 1 + .../ColorSwatch/useColorSwatch.ts | 1 + .../EmptySwatch/EmptySwatch.test.tsx | 12 + .../SwatchPicker/EmptySwatch/EmptySwatch.tsx | 14 ++ .../EmptySwatch/EmptySwatch.types.ts | 5 + .../SwatchPicker/EmptySwatch/index.ts | 4 + .../EmptySwatch/renderEmptySwatch.ts | 1 + .../EmptySwatch/useEmptySwatch.ts | 1 + .../ImageSwatch/ImageSwatch.test.tsx | 17 ++ .../SwatchPicker/ImageSwatch/ImageSwatch.tsx | 14 ++ .../ImageSwatch/ImageSwatch.types.ts | 15 ++ .../SwatchPicker/ImageSwatch/index.ts | 4 + .../ImageSwatch/renderImageSwatch.ts | 1 + .../ImageSwatch/useImageSwatch.ts | 15 ++ .../SwatchPicker/SwatchPicker.test.tsx | 94 ++++++++ .../components/SwatchPicker/SwatchPicker.tsx | 21 ++ .../SwatchPicker/SwatchPicker.types.ts | 9 + .../SwatchPickerRow/SwatchPickerRow.test.tsx | 16 ++ .../SwatchPickerRow/SwatchPickerRow.tsx | 14 ++ .../SwatchPickerRow/SwatchPickerRow.types.ts | 5 + .../SwatchPicker/SwatchPickerRow/index.ts | 4 + .../SwatchPickerRow/renderSwatchPickerRow.ts | 1 + .../SwatchPickerRow/useSwatchPickerRow.ts | 1 + .../src/components/SwatchPicker/index.ts | 16 ++ .../SwatchPicker/renderSwatchPicker.ts | 1 + .../SwatchPicker/useSwatchPicker.ts | 35 +++ .../library/src/hooks/index.ts | 2 + .../src/hooks/useGridNavigation/index.ts | 2 + .../useGridNavigation.test.tsx | 89 ++++++++ .../useGridNavigation/useGridNavigation.ts | 206 ++++++++++++++++++ .../library/src/swatch-picker.ts | 54 +++++ .../SwatchPickerDefault.stories.tsx | 34 +++ .../SwatchPicker/SwatchPickerDescription.md | 8 + .../SwatchPickerEmpty.stories.tsx | 92 ++++++++ .../SwatchPicker/SwatchPickerGrid.stories.tsx | 41 ++++ .../SwatchPickerImage.stories.tsx | 59 +++++ .../src/SwatchPicker/index.stories.tsx | 35 +++ .../src/SwatchPicker/swatch-picker.module.css | 154 +++++++++++++ yarn.lock | 1 + 47 files changed, 1284 insertions(+) create mode 100644 change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json create mode 100644 packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/renderColorSwatch.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/renderEmptySwatch.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/renderImageSwatch.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/useImageSwatch.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/renderSwatchPickerRow.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/renderSwatchPicker.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerEmpty.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerGrid.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerImage.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/swatch-picker.module.css diff --git a/change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json b/change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json new file mode 100644 index 0000000000000..f627821b83ad7 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat(react-headless-components-preview): add swatch picker components", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index e019dfd88c27a..a300b981f7653 100644 --- a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js @@ -35,6 +35,7 @@ import * as Skeleton from '@fluentui/react-headless-components-preview/skeleton' import * as Slider from '@fluentui/react-headless-components-preview/slider'; import * as SpinButton from '@fluentui/react-headless-components-preview/spin-button'; import * as Spinner from '@fluentui/react-headless-components-preview/spinner'; +import * as SwatchPicker from '@fluentui/react-headless-components-preview/swatch-picker'; import * as Switch from '@fluentui/react-headless-components-preview/switch'; import * as TabList from '@fluentui/react-headless-components-preview/tab-list'; import * as Tag from '@fluentui/react-headless-components-preview/tag'; @@ -85,6 +86,7 @@ console.log({ Slider, SpinButton, Spinner, + SwatchPicker, Switch, TabList, Tag, diff --git a/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md new file mode 100644 index 0000000000000..af62ab6d3cab6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md @@ -0,0 +1,120 @@ +## API Report File for "@fluentui/react-headless-components-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ColorSwatchBaseProps as ColorSwatchProps } from '@fluentui/react-swatch-picker'; +import { ColorSwatchSlots } from '@fluentui/react-swatch-picker'; +import { ColorSwatchBaseState as ColorSwatchState } from '@fluentui/react-swatch-picker'; +import { EmptySwatchBaseProps as EmptySwatchProps } from '@fluentui/react-swatch-picker'; +import { EmptySwatchSlots } from '@fluentui/react-swatch-picker'; +import { EmptySwatchBaseState as EmptySwatchState } from '@fluentui/react-swatch-picker'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ImageSwatchBaseProps } from '@fluentui/react-swatch-picker'; +import type { ImageSwatchBaseState } from '@fluentui/react-swatch-picker'; +import type { ImageSwatchSlots as ImageSwatchSlots_2 } from '@fluentui/react-swatch-picker'; +import type * as React_2 from 'react'; +import { renderColorSwatch_unstable as renderColorSwatch } from '@fluentui/react-swatch-picker'; +import { renderEmptySwatch_unstable as renderEmptySwatch } from '@fluentui/react-swatch-picker'; +import { renderImageSwatch_unstable as renderImageSwatch } from '@fluentui/react-swatch-picker'; +import { renderSwatchPicker_unstable as renderSwatchPicker } from '@fluentui/react-swatch-picker'; +import { renderSwatchPickerRow_unstable as renderSwatchPickerRow } from '@fluentui/react-swatch-picker'; +import type { SwatchPickerBaseState } from '@fluentui/react-swatch-picker'; +import { SwatchPickerBaseProps as SwatchPickerProps } from '@fluentui/react-swatch-picker'; +import { SwatchPickerRowBaseProps as SwatchPickerRowProps } from '@fluentui/react-swatch-picker'; +import { SwatchPickerRowSlots } from '@fluentui/react-swatch-picker'; +import { SwatchPickerRowBaseState as SwatchPickerRowState } from '@fluentui/react-swatch-picker'; +import { SwatchPickerSlots } from '@fluentui/react-swatch-picker'; +import { useColorSwatchBase_unstable as useColorSwatch } from '@fluentui/react-swatch-picker'; +import { useEmptySwatchBase_unstable as useEmptySwatch } from '@fluentui/react-swatch-picker'; +import { useSwatchPickerContextValue_unstable as useSwatchPickerContextValue } from '@fluentui/react-swatch-picker'; +import { useSwatchPickerContextValues } from '@fluentui/react-swatch-picker'; +import { useSwatchPickerRowBase_unstable as useSwatchPickerRow } from '@fluentui/react-swatch-picker'; + +// @public (undocumented) +export const ColorSwatch: ForwardRefComponent; + +export { ColorSwatchProps } + +export { ColorSwatchSlots } + +export { ColorSwatchState } + +// @public (undocumented) +export const EmptySwatch: ForwardRefComponent; + +export { EmptySwatchProps } + +export { EmptySwatchSlots } + +export { EmptySwatchState } + +// @public (undocumented) +export const ImageSwatch: ForwardRefComponent; + +// @public (undocumented) +export type ImageSwatchProps = ImageSwatchBaseProps; + +// @public (undocumented) +export type ImageSwatchSlots = ImageSwatchSlots_2; + +// @public (undocumented) +export type ImageSwatchState = ImageSwatchBaseState & { + root: { + 'data-selected'?: string; + }; +}; + +export { renderColorSwatch } + +export { renderEmptySwatch } + +export { renderImageSwatch } + +export { renderSwatchPicker } + +export { renderSwatchPickerRow } + +// @public +export const SwatchPicker: ForwardRefComponent; + +export { SwatchPickerProps } + +// @public (undocumented) +export const SwatchPickerRow: ForwardRefComponent; + +export { SwatchPickerRowProps } + +export { SwatchPickerRowSlots } + +export { SwatchPickerRowState } + +export { SwatchPickerSlots } + +// @public (undocumented) +export type SwatchPickerState = SwatchPickerBaseState & { + root: { + focusgroup?: string; + }; +}; + +export { useColorSwatch } + +export { useEmptySwatch } + +// @public (undocumented) +export const useImageSwatch: (props: ImageSwatchProps, ref: React_2.Ref) => ImageSwatchState; + +// @public (undocumented) +export const useSwatchPicker: (props: SwatchPickerProps, ref: React_2.Ref) => SwatchPickerState; + +export { useSwatchPickerContextValue } + +export { useSwatchPickerContextValues } + +export { useSwatchPickerRow } + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index 5ea5c4f77d04a..c7c4dae5dd74d 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -56,6 +56,7 @@ "@fluentui/react-slider": "^9.6.4", "@fluentui/react-spinbutton": "^9.6.4", "@fluentui/react-spinner": "^9.8.4", + "@fluentui/react-swatch-picker": "^9.5.4", "@fluentui/react-switch": "^9.7.4", "@fluentui/react-tabs": "^9.12.3", "@fluentui/react-tabster": "^9.26.16", @@ -310,6 +311,12 @@ "import": "./lib/spinner.js", "require": "./lib-commonjs/spinner.js" }, + "./swatch-picker": { + "types": "./dist/swatch-picker.d.ts", + "node": "./lib-commonjs/swatch-picker.js", + "import": "./lib/swatch-picker.js", + "require": "./lib-commonjs/swatch-picker.js" + }, "./switch": { "types": "./dist/switch.d.ts", "node": "./lib-commonjs/switch.js", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx new file mode 100644 index 0000000000000..4fa7f3c469600 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { ColorSwatch } from './ColorSwatch'; +import { SwatchPicker } from '../SwatchPicker'; + +describe('ColorSwatch', () => { + isConformant({ + Component: ColorSwatch, + displayName: 'ColorSwatch', + disabledTests: ['has-top-level-file-extra'], + }); + + it('emits selected and disabled state attributes', () => { + const { getByRole } = render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + const swatch = getByRole('radio'); + expect(swatch).toHaveAttribute('aria-label', 'Pink'); + expect(swatch).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.tsx new file mode 100644 index 0000000000000..20678a3304a61 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ColorSwatchProps } from './ColorSwatch.types'; +import { useColorSwatch } from './useColorSwatch'; +import { renderColorSwatch } from './renderColorSwatch'; + +export const ColorSwatch: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useColorSwatch(props, ref); + return renderColorSwatch(state); +}); + +ColorSwatch.displayName = 'ColorSwatch'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts new file mode 100644 index 0000000000000..c41babeded986 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts @@ -0,0 +1,5 @@ +export type { + ColorSwatchBaseProps as ColorSwatchProps, + ColorSwatchSlots, + ColorSwatchBaseState as ColorSwatchState, +} from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/index.ts new file mode 100644 index 0000000000000..8114ede193133 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/index.ts @@ -0,0 +1,4 @@ +export { ColorSwatch } from './ColorSwatch'; +export { renderColorSwatch } from './renderColorSwatch'; +export { useColorSwatch } from './useColorSwatch'; +export type { ColorSwatchSlots, ColorSwatchProps, ColorSwatchState } from './ColorSwatch.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/renderColorSwatch.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/renderColorSwatch.ts new file mode 100644 index 0000000000000..16f0c344cf809 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/renderColorSwatch.ts @@ -0,0 +1 @@ +export { renderColorSwatch_unstable as renderColorSwatch } from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts new file mode 100644 index 0000000000000..0a5be3d66b2f9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts @@ -0,0 +1 @@ +export { useColorSwatchBase_unstable as useColorSwatch } from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx new file mode 100644 index 0000000000000..2ac9429011c78 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { EmptySwatch } from './EmptySwatch'; + +describe('EmptySwatch', () => { + isConformant({ Component: EmptySwatch, displayName: 'EmptySwatch', disabledTests: ['has-top-level-file-extra'] }); + + it('renders a native radio swatch', () => { + expect(render().getByRole('radio')).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.tsx new file mode 100644 index 0000000000000..0be33ea73d7ce --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { EmptySwatchProps } from './EmptySwatch.types'; +import { useEmptySwatch } from './useEmptySwatch'; +import { renderEmptySwatch } from './renderEmptySwatch'; + +export const EmptySwatch: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useEmptySwatch(props, ref); + return renderEmptySwatch(state); +}); + +EmptySwatch.displayName = 'EmptySwatch'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts new file mode 100644 index 0000000000000..630684b102b90 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts @@ -0,0 +1,5 @@ +export type { + EmptySwatchBaseProps as EmptySwatchProps, + EmptySwatchSlots, + EmptySwatchBaseState as EmptySwatchState, +} from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/index.ts new file mode 100644 index 0000000000000..8a106691b6b56 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/index.ts @@ -0,0 +1,4 @@ +export { EmptySwatch } from './EmptySwatch'; +export { renderEmptySwatch } from './renderEmptySwatch'; +export { useEmptySwatch } from './useEmptySwatch'; +export type { EmptySwatchSlots, EmptySwatchProps, EmptySwatchState } from './EmptySwatch.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/renderEmptySwatch.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/renderEmptySwatch.ts new file mode 100644 index 0000000000000..9099162e6159f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/renderEmptySwatch.ts @@ -0,0 +1 @@ +export { renderEmptySwatch_unstable as renderEmptySwatch } from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts new file mode 100644 index 0000000000000..f6618a836f105 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts @@ -0,0 +1 @@ +export { useEmptySwatchBase_unstable as useEmptySwatch } from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.test.tsx new file mode 100644 index 0000000000000..9950a5d90a057 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.test.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { ImageSwatch } from './ImageSwatch'; +import { SwatchPicker } from '../SwatchPicker'; + +describe('ImageSwatch', () => { + isConformant({ Component: ImageSwatch, displayName: 'ImageSwatch', disabledTests: ['has-top-level-file-extra'] }); + + it('renders an image swatch with its selection state', () => { + const { getByRole } = render(, { + wrapper: ({ children }) => {children}, + }); + + expect(getByRole('radio')).toHaveAttribute('data-selected', ''); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.tsx new file mode 100644 index 0000000000000..c4af99e23b62c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ImageSwatchProps } from './ImageSwatch.types'; +import { useImageSwatch } from './useImageSwatch'; +import { renderImageSwatch } from './renderImageSwatch'; + +export const ImageSwatch: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useImageSwatch(props, ref); + return renderImageSwatch(state); +}); + +ImageSwatch.displayName = 'ImageSwatch'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts new file mode 100644 index 0000000000000..edeaf9517b539 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts @@ -0,0 +1,15 @@ +import type { + ImageSwatchBaseProps, + ImageSwatchBaseState, + ImageSwatchSlots as ImageSwatchBaseSlots, +} from '@fluentui/react-swatch-picker'; + +export type ImageSwatchProps = ImageSwatchBaseProps; + +export type ImageSwatchSlots = ImageSwatchBaseSlots; + +export type ImageSwatchState = ImageSwatchBaseState & { + root: { + 'data-selected'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/index.ts new file mode 100644 index 0000000000000..fb44b96d644b6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/index.ts @@ -0,0 +1,4 @@ +export { ImageSwatch } from './ImageSwatch'; +export { renderImageSwatch } from './renderImageSwatch'; +export { useImageSwatch } from './useImageSwatch'; +export type { ImageSwatchSlots, ImageSwatchProps, ImageSwatchState } from './ImageSwatch.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/renderImageSwatch.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/renderImageSwatch.ts new file mode 100644 index 0000000000000..c9a6d31f52513 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/renderImageSwatch.ts @@ -0,0 +1 @@ +export { renderImageSwatch_unstable as renderImageSwatch } from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/useImageSwatch.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/useImageSwatch.ts new file mode 100644 index 0000000000000..e73fc3309cfe2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/useImageSwatch.ts @@ -0,0 +1,15 @@ +'use client'; + +import type * as React from 'react'; +import { useImageSwatchBase_unstable } from '@fluentui/react-swatch-picker'; +import { stringifyDataAttribute } from '../../../utils/stringifyDataAttribute'; +import type { ImageSwatchProps, ImageSwatchState } from './ImageSwatch.types'; + +export const useImageSwatch = (props: ImageSwatchProps, ref: React.Ref): ImageSwatchState => { + const state: ImageSwatchState = useImageSwatchBase_unstable(props, ref); + + // eslint-disable-next-line react-hooks/immutability + state.root['data-selected'] = stringifyDataAttribute(state.selected); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx new file mode 100644 index 0000000000000..17a86e7eed40f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { SwatchPicker } from './SwatchPicker'; +import { ColorSwatch } from './ColorSwatch/ColorSwatch'; +import { SwatchPickerRow } from './SwatchPickerRow/SwatchPickerRow'; + +describe('SwatchPicker', () => { + isConformant({ Component: SwatchPicker, displayName: 'SwatchPicker' }); + + it('renders semantic state attributes and forwards selection', () => { + const onSelectionChange = jest.fn(); + const { getByRole } = render( + + + , + ); + + expect(getByRole('grid')).toBeInTheDocument(); + + fireEvent.click(getByRole('gridcell')); + expect(onSelectionChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ selectedValue: 'pink', selectedSwatch: '#f09' }), + ); + }); + + it('uses focusgroup for arrow navigation in a row', () => { + const { getByRole } = render( + + + , + ); + + expect(getByRole('radiogroup')).toHaveAttribute('focusgroup', 'radiogroup'); + }); + + it('moves focus through a grid with arrow keys and wraps', () => { + const { getByLabelText } = render( + + + + + + + + + + + + , + ); + const red = getByLabelText('Red'); + const yellow = getByLabelText('Yellow'); + const green = getByLabelText('Green'); + const violet = getByLabelText('Violet'); + + red.focus(); + expect(yellow).toHaveAttribute('tabindex', '-1'); + + fireEvent.keyDown(red, { key: 'ArrowRight' }); + expect(yellow).toHaveFocus(); + + fireEvent.keyDown(yellow, { key: 'ArrowDown' }); + expect(violet).toHaveFocus(); + + fireEvent.keyDown(violet, { key: 'ArrowRight' }); + expect(red).toHaveFocus(); + + fireEvent.keyDown(red, { key: 'ArrowUp' }); + expect(green).toHaveFocus(); + }); + + it('does not add grid arrow navigation in tab mode', () => { + const onKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault()); + const { getByLabelText } = render( + + + + + + , + ); + const red = getByLabelText('Red'); + const green = getByLabelText('Green'); + + red.focus(); + fireEvent.keyDown(red, { key: 'ArrowRight' }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(red).toHaveFocus(); + expect(green).not.toHaveAttribute('tabindex', '-1'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.tsx new file mode 100644 index 0000000000000..643bbfccc3d75 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { SwatchPickerProps } from './SwatchPicker.types'; +import { useSwatchPicker, useSwatchPickerContextValues } from './useSwatchPicker'; +import { renderSwatchPicker } from './renderSwatchPicker'; + +/** + * SwatchPicker represents a collection of swatches that can be selected. It is used + * to display a set of colors or images for the user to choose from. + */ +export const SwatchPicker: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useSwatchPicker(props, ref); + const contextValues = useSwatchPickerContextValues(state); + + return renderSwatchPicker(state, contextValues); +}); + +SwatchPicker.displayName = 'SwatchPicker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts new file mode 100644 index 0000000000000..c11c76128712f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts @@ -0,0 +1,9 @@ +import type { SwatchPickerBaseState } from '@fluentui/react-swatch-picker'; + +export type { SwatchPickerBaseProps as SwatchPickerProps, SwatchPickerSlots } from '@fluentui/react-swatch-picker'; + +export type SwatchPickerState = SwatchPickerBaseState & { + root: { + focusgroup?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx new file mode 100644 index 0000000000000..c68eeb1435fb6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { SwatchPickerRow } from './SwatchPickerRow'; + +describe('SwatchPickerRow', () => { + isConformant({ + Component: SwatchPickerRow, + displayName: 'SwatchPickerRow', + disabledTests: ['has-top-level-file-extra'], + }); + + it('renders a row', () => { + expect(render(Colors).getByRole('row')).toHaveTextContent('Colors'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.tsx new file mode 100644 index 0000000000000..52f98c1786aee --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.tsx @@ -0,0 +1,14 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { SwatchPickerRowProps } from './SwatchPickerRow.types'; +import { useSwatchPickerRow } from './useSwatchPickerRow'; +import { renderSwatchPickerRow } from './renderSwatchPickerRow'; + +export const SwatchPickerRow: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useSwatchPickerRow(props, ref); + return renderSwatchPickerRow(state); +}); + +SwatchPickerRow.displayName = 'SwatchPickerRow'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts new file mode 100644 index 0000000000000..629406474f9d3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts @@ -0,0 +1,5 @@ +export type { + SwatchPickerRowBaseProps as SwatchPickerRowProps, + SwatchPickerRowSlots, + SwatchPickerRowBaseState as SwatchPickerRowState, +} from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/index.ts new file mode 100644 index 0000000000000..2354688677978 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/index.ts @@ -0,0 +1,4 @@ +export { SwatchPickerRow } from './SwatchPickerRow'; +export { renderSwatchPickerRow } from './renderSwatchPickerRow'; +export { useSwatchPickerRow } from './useSwatchPickerRow'; +export type { SwatchPickerRowSlots, SwatchPickerRowProps, SwatchPickerRowState } from './SwatchPickerRow.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/renderSwatchPickerRow.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/renderSwatchPickerRow.ts new file mode 100644 index 0000000000000..b07e650b1c734 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/renderSwatchPickerRow.ts @@ -0,0 +1 @@ +export { renderSwatchPickerRow_unstable as renderSwatchPickerRow } from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts new file mode 100644 index 0000000000000..ba5ae6dcfee5a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts @@ -0,0 +1 @@ +export { useSwatchPickerRowBase_unstable as useSwatchPickerRow } from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/index.ts new file mode 100644 index 0000000000000..444ead4b97890 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/index.ts @@ -0,0 +1,16 @@ +export { SwatchPicker } from './SwatchPicker'; +export { renderSwatchPicker } from './renderSwatchPicker'; +export { useSwatchPicker, useSwatchPickerContextValues, useSwatchPickerContextValue } from './useSwatchPicker'; +export type { SwatchPickerSlots, SwatchPickerProps, SwatchPickerState } from './SwatchPicker.types'; + +export type { ColorSwatchSlots, ColorSwatchProps, ColorSwatchState } from './ColorSwatch'; +export { ColorSwatch, useColorSwatch, renderColorSwatch } from './ColorSwatch'; + +export type { EmptySwatchSlots, EmptySwatchProps, EmptySwatchState } from './EmptySwatch'; +export { EmptySwatch, useEmptySwatch, renderEmptySwatch } from './EmptySwatch'; + +export type { ImageSwatchSlots, ImageSwatchProps, ImageSwatchState } from './ImageSwatch'; +export { ImageSwatch, useImageSwatch, renderImageSwatch } from './ImageSwatch'; + +export type { SwatchPickerRowSlots, SwatchPickerRowProps, SwatchPickerRowState } from './SwatchPickerRow'; +export { SwatchPickerRow, useSwatchPickerRow, renderSwatchPickerRow } from './SwatchPickerRow'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/renderSwatchPicker.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/renderSwatchPicker.ts new file mode 100644 index 0000000000000..5d80a2ef99b8c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/renderSwatchPicker.ts @@ -0,0 +1 @@ +export { renderSwatchPicker_unstable as renderSwatchPicker } from '@fluentui/react-swatch-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts new file mode 100644 index 0000000000000..509a75dd4d7ba --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts @@ -0,0 +1,35 @@ +'use client'; + +import type * as React from 'react'; +import { useSwatchPickerBase_unstable } from '@fluentui/react-swatch-picker'; + +import { useGridNavigation } from '../../hooks'; +import type { SwatchPickerProps, SwatchPickerState } from './SwatchPicker.types'; + +export { + useSwatchPickerContextValues, + useSwatchPickerContextValue_unstable as useSwatchPickerContextValue, +} from '@fluentui/react-swatch-picker'; + +export const useSwatchPicker = (props: SwatchPickerProps, ref: React.Ref): SwatchPickerState => { + const { focusMode = 'arrow' } = props; + + const baseState: SwatchPickerState = useSwatchPickerBase_unstable(props, ref); + + const gridNavigationProps = useGridNavigation({ + circular: true, + onFocus: baseState.root.onFocus, + onKeyDown: baseState.root.onKeyDown, + }); + const rowNavigationProps = { focusgroup: 'radiogroup' }; + + if (focusMode === 'arrow') { + // eslint-disable-next-line react-hooks/immutability + baseState.root = { + ...baseState.root, + ...(baseState.isGrid ? gridNavigationProps : rowNavigationProps), + }; + } + + return baseState; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts index 14bbafd814b9b..9229e848eaca4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/index.ts @@ -1,3 +1,5 @@ +export { useGridNavigation } from './useGridNavigation'; +export type { GridNavigationProps, UseGridNavigationOptions } from './useGridNavigation'; export { usePositioning } from './usePositioning'; export type { Position, diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/index.ts new file mode 100644 index 0000000000000..c07058645b6de --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/index.ts @@ -0,0 +1,2 @@ +export { useGridNavigation } from './useGridNavigation'; +export type { GridNavigationProps, UseGridNavigationOptions } from './useGridNavigation'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.test.tsx new file mode 100644 index 0000000000000..94a3135dc8e50 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.test.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { useGridNavigation } from './useGridNavigation'; +import { Provider } from '../../components/Provider/Provider'; + +type TestGridProps = { + circular?: boolean; + homeEndNavigation?: boolean; + onKeyDown?: React.KeyboardEventHandler; +}; + +const TestGrid = (props: TestGridProps) => { + const gridNavigationProps = useGridNavigation(props); + + return ( +
+
+ + +
+
+ + +
+
+ ); +}; + +describe('useGridNavigation', () => { + it('does not wrap at grid boundaries by default', () => { + const { getByRole } = render(); + const firstCell = getByRole('gridcell', { name: 'A' }); + const lastCell = getByRole('gridcell', { name: 'D' }); + + firstCell.focus(); + fireEvent.keyDown(firstCell, { key: 'ArrowLeft' }); + expect(firstCell).toHaveFocus(); + + lastCell.focus(); + fireEvent.keyDown(lastCell, { key: 'ArrowDown' }); + expect(lastCell).toHaveFocus(); + }); + + it('does not navigate when the consumer prevents the keyboard event', () => { + const onKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault()); + const { getByRole } = render(); + const firstCell = getByRole('gridcell', { name: 'A' }); + + firstCell.focus(); + fireEvent.keyDown(firstCell, { key: 'ArrowRight' }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(firstCell).toHaveFocus(); + }); + + it('reverses horizontal arrow navigation in right-to-left mode', () => { + const { getByRole } = render(, { + wrapper: ({ children }) => {children}, + }); + const firstCell = getByRole('gridcell', { name: 'A' }); + const lastCell = getByRole('gridcell', { name: 'D' }); + + firstCell.focus(); + fireEvent.keyDown(firstCell, { key: 'ArrowRight' }); + + expect(lastCell).toHaveFocus(); + }); + + it('supports row and grid boundary navigation with Home and End', () => { + const { getByRole } = render(); + const firstCell = getByRole('gridcell', { name: 'A' }); + const secondCell = getByRole('gridcell', { name: 'B' }); + const thirdCell = getByRole('gridcell', { name: 'C' }); + const lastCell = getByRole('gridcell', { name: 'D' }); + + lastCell.focus(); + fireEvent.keyDown(lastCell, { key: 'Home' }); + expect(thirdCell).toHaveFocus(); + + fireEvent.keyDown(thirdCell, { key: 'End', ctrlKey: true }); + expect(lastCell).toHaveFocus(); + + fireEvent.keyDown(lastCell, { key: 'Home', ctrlKey: true }); + expect(firstCell).toHaveFocus(); + + fireEvent.keyDown(firstCell, { key: 'End' }); + expect(secondCell).toHaveFocus(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.ts new file mode 100644 index 0000000000000..b43153ec0f871 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.ts @@ -0,0 +1,206 @@ +'use client'; + +import type * as React from 'react'; +import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +import { mergeCallbacks, useEventCallback } from '@fluentui/react-utilities'; + +/** + * Options for {@link useGridNavigation}. + */ +export type UseGridNavigationOptions = { + /** + * Whether arrow-key navigation wraps at the first and last row or cell. + * + * @default false + */ + circular?: boolean; + + /** + * Selector used to find cells within each row and to resolve the cell targeted by an event. + * + * @default '[role="gridcell"]' + */ + cellSelector?: string; + + /** + * Whether Home and End move to the first and last enabled cell in the current row. + * Ctrl+Home and Ctrl+End move to the first and last enabled cell in the grid. + * + * @default false + */ + homeEndNavigation?: boolean; + + /** + * Determines whether a cell can receive focus during arrow-key navigation. + * By default, cells with `disabled` or `aria-disabled="true"` are skipped. + */ + isCellFocusable?: (cell: HTMLElement) => boolean; + + /** + * Existing focus handler to call before updating the grid's roving tab stops. + */ + onFocus?: React.FocusEventHandler; + + /** + * Existing keyboard handler to call before handling arrow-key navigation. + * Calling `event.preventDefault()` prevents the grid navigation handler from moving focus. + */ + onKeyDown?: React.KeyboardEventHandler; + + /** + * Selector used to find rows within the grid root. + * + * @default '[role="row"]' + */ + rowSelector?: string; +}; + +/** + * Event handlers returned by {@link useGridNavigation} for the grid root element. + */ +export type GridNavigationProps = Pick, 'onFocus' | 'onKeyDown'>; + +const defaultIsCellFocusable = (cell: HTMLElement) => + !cell.hasAttribute('disabled') && cell.getAttribute('aria-disabled') !== 'true'; + +const getGridCells = (root: HTMLElement, rowSelector: string, cellSelector: string) => + Array.from(root.querySelectorAll(rowSelector), row => + Array.from(row.querySelectorAll(cellSelector)), + ); + +const getNextIndex = (index: number, direction: number, length: number, circular: boolean) => { + const nextIndex = index + direction; + if (nextIndex >= 0 && nextIndex < length) { + return nextIndex; + } + + return circular ? (nextIndex + length) % length : -1; +}; + +const findEnabledCell = (cells: HTMLElement[], isCellFocusable: (cell: HTMLElement) => boolean, fromEnd = false) => { + const direction = fromEnd ? -1 : 1; + let index = fromEnd ? cells.length - 1 : 0; + + while (index >= 0 && index < cells.length) { + if (isCellFocusable(cells[index])) { + return cells[index]; + } + index += direction; + } + + return undefined; +}; + +/** + * Provides roving tab stops and two-dimensional arrow-key navigation for a semantic grid. + * + * The grid root must contain row elements, and each row must contain focusable cell elements. + * By default, rows are identified by `role="row"` and cells by `role="gridcell"`; custom + * selectors can be supplied when equivalent semantics use different markup. + * + * Left and Right move through cells in DOM order, including across row boundaries. Up and Down + * preserve the current column while moving between rows. Disabled cells are skipped, and focus + * movement updates `tabindex` so the most recently focused cell remains the grid's tab stop. + * When enabled, Home and End move to row boundaries, while Ctrl+Home and Ctrl+End move to grid + * boundaries. In right-to-left mode, the meanings of Left and Right are reversed. + * + * Apply the returned `onFocus` and `onKeyDown` handlers to the grid root element. + * + * @param options - Grid structure, navigation behavior, and existing root event handlers. + * @returns Event handlers to apply to the grid root. + */ +export const useGridNavigation = ( + options: UseGridNavigationOptions, +): GridNavigationProps => { + const { + cellSelector = '[role="gridcell"]', + circular = false, + homeEndNavigation = false, + isCellFocusable = defaultIsCellFocusable, + onFocus, + onKeyDown, + rowSelector = '[role="row"]', + } = options; + + const { dir } = useFluent(); + + const handleFocus = useEventCallback( + mergeCallbacks(onFocus, (event: React.FocusEvent) => { + const focusedCell = (event.target as HTMLElement).closest(cellSelector); + if (!focusedCell || !event.currentTarget.contains(focusedCell)) { + return; + } + + for (const cell of getGridCells(event.currentTarget, rowSelector, cellSelector).flat()) { + cell.tabIndex = cell === focusedCell && isCellFocusable(cell) ? 0 : -1; + } + }), + ); + + const handleKeyDown = useEventCallback( + mergeCallbacks(onKeyDown, (event: React.KeyboardEvent) => { + const navigationKeys = homeEndNavigation + ? ['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home'] + : ['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp']; + + if (event.defaultPrevented || !navigationKeys.includes(event.key)) { + return; + } + + const currentCell = (event.target as HTMLElement).closest(cellSelector); + if (!currentCell || !event.currentTarget.contains(currentCell)) { + return; + } + + const rows = getGridCells(event.currentTarget, rowSelector, cellSelector); + const rowIndex = rows.findIndex(row => row.includes(currentCell)); + const columnIndex = rows[rowIndex]?.indexOf(currentCell) ?? -1; + let nextCell: HTMLElement | undefined; + + if (event.key === 'Home' || event.key === 'End') { + const cells = event.ctrlKey ? rows.flat() : rows[rowIndex]; + nextCell = findEnabledCell(cells, isCellFocusable, event.key === 'End'); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + const cells = rows.flat(); + const inlineDirection = event.key === 'ArrowRight' ? 1 : -1; + const navigationDirection = dir === 'rtl' ? -inlineDirection : inlineDirection; + let index = cells.indexOf(currentCell); + + for (let offset = 0; offset < cells.length - 1; offset++) { + index = getNextIndex(index, navigationDirection, cells.length, circular); + if (index === -1) { + break; + } + if (isCellFocusable(cells[index])) { + nextCell = cells[index]; + break; + } + } + } else { + const blockDirection = event.key === 'ArrowDown' ? 1 : -1; + let index = rowIndex; + + for (let offset = 0; offset < rows.length - 1; offset++) { + index = getNextIndex(index, blockDirection, rows.length, circular); + if (index === -1) { + break; + } + const targetCell = rows[index][columnIndex]; + if (targetCell && isCellFocusable(targetCell)) { + nextCell = targetCell; + break; + } + } + } + + if (nextCell) { + event.preventDefault(); + currentCell.tabIndex = -1; + nextCell.tabIndex = 0; + nextCell.focus(); + } + }), + ); + + return { onFocus: handleFocus, onKeyDown: handleKeyDown }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts b/packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts new file mode 100644 index 0000000000000..5c4ab901489aa --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts @@ -0,0 +1,54 @@ +export { + // SwatchPicker + SwatchPicker, + renderSwatchPicker, + useSwatchPicker, + useSwatchPickerContextValues, + useSwatchPickerContextValue, + + // ColorSwatch + ColorSwatch, + renderColorSwatch, + useColorSwatch, + + // EmptySwatch + EmptySwatch, + renderEmptySwatch, + useEmptySwatch, + + // ImageSwatch + ImageSwatch, + renderImageSwatch, + useImageSwatch, + + // SwatchPickerRow + SwatchPickerRow, + renderSwatchPickerRow, + useSwatchPickerRow, +} from './components/SwatchPicker'; +export type { + // SwatchPicker + SwatchPickerSlots, + SwatchPickerProps, + SwatchPickerState, + + // ColorSwatch + ColorSwatchSlots, + ColorSwatchProps, + ColorSwatchState, + + // EmptySwatch + EmptySwatchSlots, + EmptySwatchProps, + EmptySwatchState, + + // ImageSwatch + ImageSwatchSlots, + ImageSwatchProps, + ImageSwatchState, + + // SwatchPickerRow + SwatchPickerRowSlots, + SwatchPickerRowProps, + SwatchPickerRowState, +} from './components/SwatchPicker'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDefault.stories.tsx new file mode 100644 index 0000000000000..46a00aa58f899 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDefault.stories.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { ColorSwatch, SwatchPicker } from '@fluentui/react-headless-components-preview/swatch-picker'; +import styles from './swatch-picker.module.css'; + +const colors = [ + ['#e11d48', 'Red'], + ['#f97316', 'Orange'], + ['#84cc16', 'Green'], + ['#06b6d4', 'Cyan'], + ['#2563eb', 'Blue'], + ['#9333ea', 'Purple'], +]; + +export const Default = (): React.ReactNode => { + const [selected, setSelected] = React.useState(colors[0][0]); + + return ( +
+ setSelected(data.selectedValue)} + className={styles.picker} + focusMode="arrow" + layout="row" + > + {colors.map(([color, label]) => ( + + ))} + +
+
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md new file mode 100644 index 0000000000000..93b7deca1f283 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md @@ -0,0 +1,8 @@ +SwatchPicker provides a semantic radio group or grid of selectable color and image swatches. + +Use `ColorSwatch`, `ImageSwatch`, and `EmptySwatch` as children of `SwatchPicker`. Give the picker +an accessible name and give each swatch an accessible name. The headless components expose +`data-layout`, `data-focus-mode`, `data-selected`, and `data-disabled` for styling behavior. + +When `layout="grid"`, group swatches into `SwatchPickerRow` children. The row structure provides +the grid semantics and coordinates used by arrow-key navigation; CSS controls only the visual layout. diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerEmpty.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerEmpty.stories.tsx new file mode 100644 index 0000000000000..2ceec7eadd935 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerEmpty.stories.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { ColorSwatch, EmptySwatch, SwatchPicker } from '@fluentui/react-headless-components-preview/swatch-picker'; +import styles from './swatch-picker.module.css'; + +const ITEMS_LIMIT = 8; + +const defaultItems = [ + { color: '#e11d48', value: '#e11d48', label: 'Red' }, + { color: '#f97316', value: '#f97316', label: 'Orange' }, + { color: '#84cc16', value: '#84cc16', label: 'Green' }, + { color: '#06b6d4', value: '#06b6d4', label: 'Cyan' }, +]; + +export const EmptySwatchExample = (): React.ReactNode => { + const [items, setItems] = React.useState(defaultItems); + const [selectedValue, setSelectedValue] = React.useState(defaultItems[0].value); + const [customColor, setCustomColor] = React.useState('#2563eb'); + const addedSwatchRef = React.useRef(null); + + const emptyItems = Array.from({ length: ITEMS_LIMIT - items.length }); + + const addColor = () => { + const value = `${customColor}-${items.length}`; + setItems(currentItems => [...currentItems, { color: customColor, value, label: customColor }]); + setSelectedValue(value); + }; + + React.useEffect(() => { + if (items.length > defaultItems.length) { + addedSwatchRef.current?.focus(); + } + }, [items.length]); + + return ( +
+ setSelectedValue(data.selectedValue)} + className={styles.emptyPicker} + > + {items.map((item, index) => ( + defaultItems.length ? addedSwatchRef : undefined} + color={item.color} + value={item.value} + aria-label={item.label} + className={styles.swatch} + /> + ))} + {emptyItems.map((_, index) => ( + + ))} + + +
+ +
+ setCustomColor(event.target.value)} + /> + + +
+
+ ); +}; + +EmptySwatchExample.parameters = { + docs: { + description: { + story: 'Empty swatches reserve space for colors that can be added later.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerGrid.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerGrid.stories.tsx new file mode 100644 index 0000000000000..2fc4738ec6278 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerGrid.stories.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { ColorSwatch, SwatchPicker, SwatchPickerRow } from '@fluentui/react-headless-components-preview/swatch-picker'; +import styles from './swatch-picker.module.css'; + +const colors = [ + [ + ['#e11d48', 'Red'], + ['#f97316', 'Orange'], + ['#84cc16', 'Green'], + ], + [ + ['#06b6d4', 'Cyan'], + ['#2563eb', 'Blue'], + ['#9333ea', 'Purple'], + ], +]; + +export const Grid = (): React.ReactNode => { + const [selected, setSelected] = React.useState(colors[0][0][0]); + + return ( +
+ setSelected(data.selectedValue)} + layout="grid" + className={styles.grid} + > + {colors.map((row, rowIndex) => ( + + {row.map(([color, label]) => ( + + ))} + + ))} + +
+
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerImage.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerImage.stories.tsx new file mode 100644 index 0000000000000..973d3b9e53702 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerImage.stories.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { ImageSwatch, SwatchPicker } from '@fluentui/react-headless-components-preview/swatch-picker'; +import styles from './swatch-picker.module.css'; + +const images = [ + { + swatchSrc: 'https://fabricweb.azureedge.net/fabric-website/assets/images/swatch-picker/sea-swatch.jpg', + value: 'sea', + label: 'Sea', + fullImageSrc: 'https://fabricweb.azureedge.net/fabric-website/assets/images/swatch-picker/sea-full-img.jpg', + }, + { + swatchSrc: 'https://fabricweb.azureedge.net/fabric-website/assets/images/swatch-picker/bridge-swatch.jpg', + value: 'bridge', + label: 'Bridge', + fullImageSrc: 'https://fabricweb.azureedge.net/fabric-website/assets/images/swatch-picker/bridge-full-img.jpg', + }, + { + swatchSrc: 'https://fabricweb.azureedge.net/fabric-website/assets/images/swatch-picker/park-swatch.jpg', + value: 'park', + label: 'Park', + fullImageSrc: 'https://fabricweb.azureedge.net/fabric-website/assets/images/swatch-picker/park-full-img.jpg', + }, +]; + +export const ImageSwatchExample = (): React.ReactNode => { + const [selectedValue, setSelectedValue] = React.useState('bridge'); + const selectedImage = images.find(image => image.value === selectedValue) ?? images[0]; + + return ( +
+ setSelectedValue(data.selectedValue)} + className={styles.imagePicker} + > + {images.map(image => ( + + ))} + + {`${selectedImage.label} +
+ ); +}; + +ImageSwatchExample.parameters = { + docs: { + description: { + story: 'A swatch can use an image thumbnail and control a larger image preview.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx new file mode 100644 index 0000000000000..95e26b5ab6491 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { + ColorSwatch, + EmptySwatch, + ImageSwatch, + SwatchPicker, + SwatchPickerRow, +} from '@fluentui/react-headless-components-preview/swatch-picker'; +import descriptionMd from './SwatchPickerDescription.md'; +import styles from './swatch-picker.module.css'; + +export { Default } from './SwatchPickerDefault.stories'; +export { EmptySwatchExample } from './SwatchPickerEmpty.stories'; +export { Grid } from './SwatchPickerGrid.stories'; +export { ImageSwatchExample } from './SwatchPickerImage.stories'; + +export default { + title: 'Components/SwatchPicker', + component: SwatchPicker, + subcomponents: { ColorSwatch, EmptySwatch, ImageSwatch, SwatchPickerRow }, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, + decorators: [ + (Story: React.ComponentType): React.ReactNode => ( +
+ +
+ ), + ], +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/swatch-picker.module.css b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/swatch-picker.module.css new file mode 100644 index 0000000000000..10417aa37f8c7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/swatch-picker.module.css @@ -0,0 +1,154 @@ +.story { + display: block; + padding: var(--space-6); + color: var(--text); + background: var(--bg); +} + +.demo { + display: grid; + gap: var(--space-4); +} + +.picker { + display: grid; + gap: var(--space-2); + grid-template-columns: repeat(6, var(--space-8)); +} + +.grid { + display: grid; + gap: var(--space-2); +} + +.gridRow { + display: grid; + grid-template-columns: repeat(3, var(--space-8)); + gap: var(--space-2); +} + +.emptyPicker { + display: grid; + grid-template-columns: repeat(8, var(--space-8)); + gap: var(--space-2); +} + +.swatch { + width: var(--space-8); + height: var(--space-8); + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + background: var(--fui-SwatchPicker--color); +} + +.emptySwatch { + width: var(--space-8); + height: var(--space-8); + padding: 0; + border: var(--stroke-thin) dashed var(--border-stronger); + border-radius: var(--radius-sm); + background: transparent; +} + +.imageDemo { + display: grid; + gap: var(--space-5); +} + +.imagePicker { + display: flex; + gap: var(--space-3); +} + +.imageSwatch { + width: calc(var(--space-16) + var(--space-10) - var(--space-1)); + height: calc(var(--space-16) + var(--space-10) - var(--space-1)); + padding: 0; + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-sm); + background-position: center; + background-size: cover; + cursor: pointer; +} + +.imagePreview { + display: block; + width: 100%; + max-width: calc(var(--space-16) * 10); + aspect-ratio: 16 / 9; + border-radius: var(--radius-sm); + object-fit: cover; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.colorInput { + width: var(--space-10); + height: var(--space-10); + padding: var(--space-1); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg); +} + +.button { + min-height: var(--space-10); + padding-inline: var(--space-4); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + background: var(--bg-elev); +} + +.button:hover:not(:disabled) { + border-color: var(--accent); +} + +.button:disabled { + color: var(--text-faint); + cursor: not-allowed; +} + +.swatch[aria-checked='true'], +.swatch[aria-selected='true'], +.imageSwatch[aria-checked='true'], +.imageSwatch[aria-selected='true'] { + border-color: var(--text); + box-shadow: inset 0 0 var(--stroke-thick) var(--text); +} + +.swatch:focus-visible, +.imageSwatch:focus-visible, +.button:focus-visible, +.colorInput:focus-visible { + outline: var(--stroke-thick) solid var(--accent-strong); + outline-offset: var(--space-1); +} + +@media (forced-colors: active) { + .swatch[aria-checked='true'], + .swatch[aria-selected='true'], + .imageSwatch[aria-checked='true'], + .imageSwatch[aria-selected='true'] { + border-color: Highlight; + box-shadow: inset 0 0 var(--stroke-thick) Highlight; + } +} + +.selectedColor { + width: calc(var(--space-16) + var(--space-8) + var(--space-1)); + height: calc(var(--space-16) + var(--space-8) + var(--space-1)); + border: var(--stroke-thin) solid var(--border); +} + +@media (forced-colors: active) { + .selectedColor { + forced-color-adjust: none; + } +} diff --git a/yarn.lock b/yarn.lock index 59dca6f0f0833..ad1847e07e9e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4358,6 +4358,7 @@ __metadata: "@fluentui/react-slider": "npm:^9.6.4" "@fluentui/react-spinbutton": "npm:^9.6.4" "@fluentui/react-spinner": "npm:^9.8.4" + "@fluentui/react-swatch-picker": "npm:^9.5.4" "@fluentui/react-switch": "npm:^9.7.4" "@fluentui/react-tabs": "npm:^9.12.3" "@fluentui/react-tabster": "npm:^9.26.16" From ae8dc79c9f0a60272421e7e8f6335efb5d6fc976 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Thu, 6 Aug 2026 21:13:55 +0200 Subject: [PATCH 2/8] Change version type from minor to patch --- ...mponents-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json b/change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json index f627821b83ad7..d9e0ca2793c45 100644 --- a/change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json +++ b/change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json @@ -1,6 +1,6 @@ { - "type": "minor", - "comment": "feat(react-headless-components-preview): add swatch picker components", + "type": "patch", + "comment": "feat: add swatch picker components", "packageName": "@fluentui/react-headless-components-preview", "email": "dmytrokirpa@microsoft.com", "dependentChangeType": "patch" From 74dfeacb8ad46bb208d6670a00d745aa6958d114 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 7 Aug 2026 12:47:27 +0200 Subject: [PATCH 3/8] remove useGridNavigation hook and related tests from headless components --- .../SwatchPicker/useSwatchPicker.ts | 14 +- .../library/src/hooks/index.ts | 2 - .../src/hooks/useGridNavigation/index.ts | 2 - .../useGridNavigation.test.tsx | 89 -------- .../useGridNavigation/useGridNavigation.ts | 206 ------------------ 5 files changed, 1 insertion(+), 312 deletions(-) delete mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/index.ts delete mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.test.tsx delete mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.ts diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts index 509a75dd4d7ba..6358b8d832d03 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts @@ -2,8 +2,6 @@ import type * as React from 'react'; import { useSwatchPickerBase_unstable } from '@fluentui/react-swatch-picker'; - -import { useGridNavigation } from '../../hooks'; import type { SwatchPickerProps, SwatchPickerState } from './SwatchPicker.types'; export { @@ -16,19 +14,9 @@ export const useSwatchPicker = (props: SwatchPickerProps, ref: React.Ref; -}; - -const TestGrid = (props: TestGridProps) => { - const gridNavigationProps = useGridNavigation(props); - - return ( -
-
- - -
-
- - -
-
- ); -}; - -describe('useGridNavigation', () => { - it('does not wrap at grid boundaries by default', () => { - const { getByRole } = render(); - const firstCell = getByRole('gridcell', { name: 'A' }); - const lastCell = getByRole('gridcell', { name: 'D' }); - - firstCell.focus(); - fireEvent.keyDown(firstCell, { key: 'ArrowLeft' }); - expect(firstCell).toHaveFocus(); - - lastCell.focus(); - fireEvent.keyDown(lastCell, { key: 'ArrowDown' }); - expect(lastCell).toHaveFocus(); - }); - - it('does not navigate when the consumer prevents the keyboard event', () => { - const onKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault()); - const { getByRole } = render(); - const firstCell = getByRole('gridcell', { name: 'A' }); - - firstCell.focus(); - fireEvent.keyDown(firstCell, { key: 'ArrowRight' }); - - expect(onKeyDown).toHaveBeenCalledTimes(1); - expect(firstCell).toHaveFocus(); - }); - - it('reverses horizontal arrow navigation in right-to-left mode', () => { - const { getByRole } = render(, { - wrapper: ({ children }) => {children}, - }); - const firstCell = getByRole('gridcell', { name: 'A' }); - const lastCell = getByRole('gridcell', { name: 'D' }); - - firstCell.focus(); - fireEvent.keyDown(firstCell, { key: 'ArrowRight' }); - - expect(lastCell).toHaveFocus(); - }); - - it('supports row and grid boundary navigation with Home and End', () => { - const { getByRole } = render(); - const firstCell = getByRole('gridcell', { name: 'A' }); - const secondCell = getByRole('gridcell', { name: 'B' }); - const thirdCell = getByRole('gridcell', { name: 'C' }); - const lastCell = getByRole('gridcell', { name: 'D' }); - - lastCell.focus(); - fireEvent.keyDown(lastCell, { key: 'Home' }); - expect(thirdCell).toHaveFocus(); - - fireEvent.keyDown(thirdCell, { key: 'End', ctrlKey: true }); - expect(lastCell).toHaveFocus(); - - fireEvent.keyDown(lastCell, { key: 'Home', ctrlKey: true }); - expect(firstCell).toHaveFocus(); - - fireEvent.keyDown(firstCell, { key: 'End' }); - expect(secondCell).toHaveFocus(); - }); -}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.ts deleted file mode 100644 index b43153ec0f871..0000000000000 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/useGridNavigation/useGridNavigation.ts +++ /dev/null @@ -1,206 +0,0 @@ -'use client'; - -import type * as React from 'react'; -import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; -import { mergeCallbacks, useEventCallback } from '@fluentui/react-utilities'; - -/** - * Options for {@link useGridNavigation}. - */ -export type UseGridNavigationOptions = { - /** - * Whether arrow-key navigation wraps at the first and last row or cell. - * - * @default false - */ - circular?: boolean; - - /** - * Selector used to find cells within each row and to resolve the cell targeted by an event. - * - * @default '[role="gridcell"]' - */ - cellSelector?: string; - - /** - * Whether Home and End move to the first and last enabled cell in the current row. - * Ctrl+Home and Ctrl+End move to the first and last enabled cell in the grid. - * - * @default false - */ - homeEndNavigation?: boolean; - - /** - * Determines whether a cell can receive focus during arrow-key navigation. - * By default, cells with `disabled` or `aria-disabled="true"` are skipped. - */ - isCellFocusable?: (cell: HTMLElement) => boolean; - - /** - * Existing focus handler to call before updating the grid's roving tab stops. - */ - onFocus?: React.FocusEventHandler; - - /** - * Existing keyboard handler to call before handling arrow-key navigation. - * Calling `event.preventDefault()` prevents the grid navigation handler from moving focus. - */ - onKeyDown?: React.KeyboardEventHandler; - - /** - * Selector used to find rows within the grid root. - * - * @default '[role="row"]' - */ - rowSelector?: string; -}; - -/** - * Event handlers returned by {@link useGridNavigation} for the grid root element. - */ -export type GridNavigationProps = Pick, 'onFocus' | 'onKeyDown'>; - -const defaultIsCellFocusable = (cell: HTMLElement) => - !cell.hasAttribute('disabled') && cell.getAttribute('aria-disabled') !== 'true'; - -const getGridCells = (root: HTMLElement, rowSelector: string, cellSelector: string) => - Array.from(root.querySelectorAll(rowSelector), row => - Array.from(row.querySelectorAll(cellSelector)), - ); - -const getNextIndex = (index: number, direction: number, length: number, circular: boolean) => { - const nextIndex = index + direction; - if (nextIndex >= 0 && nextIndex < length) { - return nextIndex; - } - - return circular ? (nextIndex + length) % length : -1; -}; - -const findEnabledCell = (cells: HTMLElement[], isCellFocusable: (cell: HTMLElement) => boolean, fromEnd = false) => { - const direction = fromEnd ? -1 : 1; - let index = fromEnd ? cells.length - 1 : 0; - - while (index >= 0 && index < cells.length) { - if (isCellFocusable(cells[index])) { - return cells[index]; - } - index += direction; - } - - return undefined; -}; - -/** - * Provides roving tab stops and two-dimensional arrow-key navigation for a semantic grid. - * - * The grid root must contain row elements, and each row must contain focusable cell elements. - * By default, rows are identified by `role="row"` and cells by `role="gridcell"`; custom - * selectors can be supplied when equivalent semantics use different markup. - * - * Left and Right move through cells in DOM order, including across row boundaries. Up and Down - * preserve the current column while moving between rows. Disabled cells are skipped, and focus - * movement updates `tabindex` so the most recently focused cell remains the grid's tab stop. - * When enabled, Home and End move to row boundaries, while Ctrl+Home and Ctrl+End move to grid - * boundaries. In right-to-left mode, the meanings of Left and Right are reversed. - * - * Apply the returned `onFocus` and `onKeyDown` handlers to the grid root element. - * - * @param options - Grid structure, navigation behavior, and existing root event handlers. - * @returns Event handlers to apply to the grid root. - */ -export const useGridNavigation = ( - options: UseGridNavigationOptions, -): GridNavigationProps => { - const { - cellSelector = '[role="gridcell"]', - circular = false, - homeEndNavigation = false, - isCellFocusable = defaultIsCellFocusable, - onFocus, - onKeyDown, - rowSelector = '[role="row"]', - } = options; - - const { dir } = useFluent(); - - const handleFocus = useEventCallback( - mergeCallbacks(onFocus, (event: React.FocusEvent) => { - const focusedCell = (event.target as HTMLElement).closest(cellSelector); - if (!focusedCell || !event.currentTarget.contains(focusedCell)) { - return; - } - - for (const cell of getGridCells(event.currentTarget, rowSelector, cellSelector).flat()) { - cell.tabIndex = cell === focusedCell && isCellFocusable(cell) ? 0 : -1; - } - }), - ); - - const handleKeyDown = useEventCallback( - mergeCallbacks(onKeyDown, (event: React.KeyboardEvent) => { - const navigationKeys = homeEndNavigation - ? ['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'End', 'Home'] - : ['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp']; - - if (event.defaultPrevented || !navigationKeys.includes(event.key)) { - return; - } - - const currentCell = (event.target as HTMLElement).closest(cellSelector); - if (!currentCell || !event.currentTarget.contains(currentCell)) { - return; - } - - const rows = getGridCells(event.currentTarget, rowSelector, cellSelector); - const rowIndex = rows.findIndex(row => row.includes(currentCell)); - const columnIndex = rows[rowIndex]?.indexOf(currentCell) ?? -1; - let nextCell: HTMLElement | undefined; - - if (event.key === 'Home' || event.key === 'End') { - const cells = event.ctrlKey ? rows.flat() : rows[rowIndex]; - nextCell = findEnabledCell(cells, isCellFocusable, event.key === 'End'); - } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - const cells = rows.flat(); - const inlineDirection = event.key === 'ArrowRight' ? 1 : -1; - const navigationDirection = dir === 'rtl' ? -inlineDirection : inlineDirection; - let index = cells.indexOf(currentCell); - - for (let offset = 0; offset < cells.length - 1; offset++) { - index = getNextIndex(index, navigationDirection, cells.length, circular); - if (index === -1) { - break; - } - if (isCellFocusable(cells[index])) { - nextCell = cells[index]; - break; - } - } - } else { - const blockDirection = event.key === 'ArrowDown' ? 1 : -1; - let index = rowIndex; - - for (let offset = 0; offset < rows.length - 1; offset++) { - index = getNextIndex(index, blockDirection, rows.length, circular); - if (index === -1) { - break; - } - const targetCell = rows[index][columnIndex]; - if (targetCell && isCellFocusable(targetCell)) { - nextCell = targetCell; - break; - } - } - } - - if (nextCell) { - event.preventDefault(); - currentCell.tabIndex = -1; - nextCell.tabIndex = 0; - nextCell.focus(); - } - }), - ); - - return { onFocus: handleFocus, onKeyDown: handleKeyDown }; -}; From 384dbacd39155ffd7d10a87ef881177e0c786c73 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 7 Aug 2026 21:59:42 +0200 Subject: [PATCH 4/8] fix(react-headless-components-preview): address swatch picker review --- .../library/etc/swatch-picker.api.md | 38 +++++++----- .../ColorSwatch/ColorSwatch.test.tsx | 4 +- .../ColorSwatch/ColorSwatch.types.ts | 21 +++++-- .../ColorSwatch/useColorSwatch.ts | 18 +++++- .../EmptySwatch/EmptySwatch.test.tsx | 8 ++- .../EmptySwatch/EmptySwatch.types.ts | 21 +++++-- .../EmptySwatch/useEmptySwatch.ts | 18 +++++- .../ImageSwatch/ImageSwatch.test.tsx | 2 +- .../ImageSwatch/ImageSwatch.types.ts | 13 ++--- .../SwatchPicker/SwatchPicker.cy.tsx | 39 +++++++++++++ .../SwatchPicker/SwatchPicker.test.tsx | 58 +++++-------------- .../SwatchPicker/SwatchPicker.types.ts | 7 +++ .../SwatchPicker/useSwatchPicker.ts | 7 ++- .../library/src/swatch-picker.ts | 18 ------ .../SwatchPicker/SwatchPickerDescription.md | 3 +- .../src/SwatchPicker/index.stories.tsx | 3 +- .../src/shared/browserSupportNotice.ts | 1 + 17 files changed, 174 insertions(+), 105 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx diff --git a/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md index af62ab6d3cab6..1353ee85676e9 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md @@ -4,15 +4,15 @@ ```ts +import type { ColorSwatchBaseState } from '@fluentui/react-swatch-picker'; import { ColorSwatchBaseProps as ColorSwatchProps } from '@fluentui/react-swatch-picker'; -import { ColorSwatchSlots } from '@fluentui/react-swatch-picker'; -import { ColorSwatchBaseState as ColorSwatchState } from '@fluentui/react-swatch-picker'; +import type { ColorSwatchSlots } from '@fluentui/react-swatch-picker'; +import type { EmptySwatchBaseState } from '@fluentui/react-swatch-picker'; import { EmptySwatchBaseProps as EmptySwatchProps } from '@fluentui/react-swatch-picker'; -import { EmptySwatchSlots } from '@fluentui/react-swatch-picker'; -import { EmptySwatchBaseState as EmptySwatchState } from '@fluentui/react-swatch-picker'; +import type { EmptySwatchSlots } from '@fluentui/react-swatch-picker'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; -import type { ImageSwatchBaseProps } from '@fluentui/react-swatch-picker'; import type { ImageSwatchBaseState } from '@fluentui/react-swatch-picker'; +import { ImageSwatchBaseProps as ImageSwatchProps } from '@fluentui/react-swatch-picker'; import type { ImageSwatchSlots as ImageSwatchSlots_2 } from '@fluentui/react-swatch-picker'; import type * as React_2 from 'react'; import { renderColorSwatch_unstable as renderColorSwatch } from '@fluentui/react-swatch-picker'; @@ -26,8 +26,6 @@ import { SwatchPickerRowBaseProps as SwatchPickerRowProps } from '@fluentui/reac import { SwatchPickerRowSlots } from '@fluentui/react-swatch-picker'; import { SwatchPickerRowBaseState as SwatchPickerRowState } from '@fluentui/react-swatch-picker'; import { SwatchPickerSlots } from '@fluentui/react-swatch-picker'; -import { useColorSwatchBase_unstable as useColorSwatch } from '@fluentui/react-swatch-picker'; -import { useEmptySwatchBase_unstable as useEmptySwatch } from '@fluentui/react-swatch-picker'; import { useSwatchPickerContextValue_unstable as useSwatchPickerContextValue } from '@fluentui/react-swatch-picker'; import { useSwatchPickerContextValues } from '@fluentui/react-swatch-picker'; import { useSwatchPickerRowBase_unstable as useSwatchPickerRow } from '@fluentui/react-swatch-picker'; @@ -39,7 +37,13 @@ export { ColorSwatchProps } export { ColorSwatchSlots } -export { ColorSwatchState } +// @public (undocumented) +export type ColorSwatchState = ColorSwatchBaseState & { + root: { + 'data-selected'?: string; + 'data-disabled'?: string; + }; +}; // @public (undocumented) export const EmptySwatch: ForwardRefComponent; @@ -48,13 +52,18 @@ export { EmptySwatchProps } export { EmptySwatchSlots } -export { EmptySwatchState } +// @public (undocumented) +export type EmptySwatchState = EmptySwatchBaseState & { + root: { + 'data-selected'?: string; + 'data-disabled'?: string; + }; +}; // @public (undocumented) export const ImageSwatch: ForwardRefComponent; -// @public (undocumented) -export type ImageSwatchProps = ImageSwatchBaseProps; +export { ImageSwatchProps } // @public (undocumented) export type ImageSwatchSlots = ImageSwatchSlots_2; @@ -95,13 +104,16 @@ export { SwatchPickerSlots } // @public (undocumented) export type SwatchPickerState = SwatchPickerBaseState & { root: { + 'data-layout'?: SwatchPickerBaseState['layout']; focusgroup?: string; }; }; -export { useColorSwatch } +// @public (undocumented) +export const useColorSwatch: (props: ColorSwatchProps, ref: React_2.Ref) => ColorSwatchState; -export { useEmptySwatch } +// @public (undocumented) +export const useEmptySwatch: (props: EmptySwatchProps, ref: React_2.Ref) => EmptySwatchState; // @public (undocumented) export const useImageSwatch: (props: ImageSwatchProps, ref: React_2.Ref) => ImageSwatchState; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx index 4fa7f3c469600..3ada7f41c6b38 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx @@ -12,7 +12,7 @@ describe('ColorSwatch', () => { }); it('emits selected and disabled state attributes', () => { - const { getByRole } = render(, { + const { getByRole } = render(, { wrapper: ({ children }) => ( {children} @@ -22,5 +22,7 @@ describe('ColorSwatch', () => { const swatch = getByRole('radio'); expect(swatch).toHaveAttribute('aria-label', 'Pink'); expect(swatch).toHaveAttribute('aria-checked', 'true'); + expect(swatch).toHaveAttribute('data-selected'); + expect(swatch).toHaveAttribute('data-disabled'); }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts index c41babeded986..7505a6bcdac7f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts @@ -1,5 +1,16 @@ -export type { - ColorSwatchBaseProps as ColorSwatchProps, - ColorSwatchSlots, - ColorSwatchBaseState as ColorSwatchState, -} from '@fluentui/react-swatch-picker'; +import type { ColorSwatchBaseState } from '@fluentui/react-swatch-picker'; + +export type { ColorSwatchBaseProps as ColorSwatchProps, ColorSwatchSlots } from '@fluentui/react-swatch-picker'; + +export type ColorSwatchState = ColorSwatchBaseState & { + root: { + /** + * Whether ColorSwatch is selected + */ + 'data-selected'?: string; + /** + * Whether ColorSwatch is disabled + */ + 'data-disabled'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts index 0a5be3d66b2f9..055de0d123e37 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts @@ -1 +1,17 @@ -export { useColorSwatchBase_unstable as useColorSwatch } from '@fluentui/react-swatch-picker'; +'use client'; + +import type * as React from 'react'; +import { useColorSwatchBase_unstable } from '@fluentui/react-swatch-picker'; +import { stringifyDataAttribute } from '../../../utils'; +import type { ColorSwatchProps, ColorSwatchState } from './ColorSwatch.types'; + +export const useColorSwatch = (props: ColorSwatchProps, ref: React.Ref): ColorSwatchState => { + const state: ColorSwatchState = useColorSwatchBase_unstable(props, ref); + + // eslint-disable-next-line react-hooks/immutability + state.root['data-selected'] = stringifyDataAttribute(state.selected); + // eslint-disable-next-line react-hooks/immutability + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx index 2ac9429011c78..ee7adf43040ff 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx @@ -6,7 +6,11 @@ import { EmptySwatch } from './EmptySwatch'; describe('EmptySwatch', () => { isConformant({ Component: EmptySwatch, displayName: 'EmptySwatch', disabledTests: ['has-top-level-file-extra'] }); - it('renders a native radio swatch', () => { - expect(render().getByRole('radio')).toHaveAttribute('aria-checked', 'false'); + it('renders a native radio swatch with state attributes', () => { + const swatch = render().getByRole('radio'); + + expect(swatch).toHaveAttribute('aria-checked', 'false'); + expect(swatch).not.toHaveAttribute('data-selected'); + expect(swatch).toHaveAttribute('data-disabled'); }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts index 630684b102b90..60e97faf83298 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts @@ -1,5 +1,16 @@ -export type { - EmptySwatchBaseProps as EmptySwatchProps, - EmptySwatchSlots, - EmptySwatchBaseState as EmptySwatchState, -} from '@fluentui/react-swatch-picker'; +import type { EmptySwatchBaseState } from '@fluentui/react-swatch-picker'; + +export type { EmptySwatchBaseProps as EmptySwatchProps, EmptySwatchSlots } from '@fluentui/react-swatch-picker'; + +export type EmptySwatchState = EmptySwatchBaseState & { + root: { + /** + * Whether EmptySwatch is selected + */ + 'data-selected'?: string; + /** + * Whether EmptySwatch is disabled + */ + 'data-disabled'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts index f6618a836f105..20c685ba560a7 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts @@ -1 +1,17 @@ -export { useEmptySwatchBase_unstable as useEmptySwatch } from '@fluentui/react-swatch-picker'; +'use client'; + +import type * as React from 'react'; +import { useEmptySwatchBase_unstable } from '@fluentui/react-swatch-picker'; +import { stringifyDataAttribute } from '../../../utils'; +import type { EmptySwatchProps, EmptySwatchState } from './EmptySwatch.types'; + +export const useEmptySwatch = (props: EmptySwatchProps, ref: React.Ref): EmptySwatchState => { + const state: EmptySwatchState = useEmptySwatchBase_unstable(props, ref); + + // eslint-disable-next-line react-hooks/immutability + state.root['data-selected'] = stringifyDataAttribute(state.root['aria-checked']); + // eslint-disable-next-line react-hooks/immutability + state.root['data-disabled'] = stringifyDataAttribute(state.root.disabled); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.test.tsx index 9950a5d90a057..8e6f342d5933c 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.test.tsx @@ -12,6 +12,6 @@ describe('ImageSwatch', () => { wrapper: ({ children }) => {children}, }); - expect(getByRole('radio')).toHaveAttribute('data-selected', ''); + expect(getByRole('radio')).toHaveAttribute('data-selected'); }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts index edeaf9517b539..5accc268e485a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts @@ -1,15 +1,12 @@ -import type { - ImageSwatchBaseProps, - ImageSwatchBaseState, - ImageSwatchSlots as ImageSwatchBaseSlots, -} from '@fluentui/react-swatch-picker'; +import type { ImageSwatchBaseState } from '@fluentui/react-swatch-picker'; -export type ImageSwatchProps = ImageSwatchBaseProps; - -export type ImageSwatchSlots = ImageSwatchBaseSlots; +export type { ImageSwatchSlots, ImageSwatchBaseProps as ImageSwatchProps } from '@fluentui/react-swatch-picker'; export type ImageSwatchState = ImageSwatchBaseState & { root: { + /** + * Whether ImageSwatch is selected + */ 'data-selected'?: string; }; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx new file mode 100644 index 0000000000000..c02564cb4afa0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill'; +import type { JSXElement } from '@fluentui/react-utilities'; + +import { Provider } from '../Provider'; +import { SwatchPicker } from './SwatchPicker'; +import { ColorSwatch } from './ColorSwatch/ColorSwatch'; +import { SwatchPickerRow } from './SwatchPickerRow/SwatchPickerRow'; + +polyfillBodyAndObserve(); + +const mount = (element: JSXElement) => mountBase({element}); + +describe('SwatchPicker', () => { + it('moves focus through a grid with arrow keys and wraps', () => { + mount( + + + + + + + + + + + + , + ); + + cy.get('[role="grid"]').should('have.attr', 'focusgroup', 'grid'); + cy.get('[role="grid"]').should('not.have.attr', 'data-tabster'); + cy.get('[aria-label="Red"]').focus().realPress('ArrowRight'); + cy.get('[aria-label="Yellow"]').should('be.focused').realPress('ArrowDown'); + cy.get('[aria-label="Violet"]').should('be.focused').realPress('ArrowRight'); + cy.get('[aria-label="Red"]').should('be.focused'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx index 17a86e7eed40f..ee54973b8679a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { fireEvent, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { isConformant } from '../../testing/isConformant'; import { SwatchPicker } from './SwatchPicker'; import { ColorSwatch } from './ColorSwatch/ColorSwatch'; @@ -8,7 +9,7 @@ import { SwatchPickerRow } from './SwatchPickerRow/SwatchPickerRow'; describe('SwatchPicker', () => { isConformant({ Component: SwatchPicker, displayName: 'SwatchPicker' }); - it('renders semantic state attributes and forwards selection', () => { + it('renders semantic state attributes and forwards selection', async () => { const onSelectionChange = jest.fn(); const { getByRole } = render( @@ -16,62 +17,29 @@ describe('SwatchPicker', () => { , ); - expect(getByRole('grid')).toBeInTheDocument(); + expect(getByRole('grid')).toHaveAttribute('data-layout', 'grid'); - fireEvent.click(getByRole('gridcell')); + await userEvent.click(getByRole('gridcell')); expect(onSelectionChange).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ selectedValue: 'pink', selectedSwatch: '#f09' }), ); }); - it('uses focusgroup for arrow navigation in a row', () => { + it.each([ + ['row', 'radiogroup', 'radiogroup'], + ['grid', 'grid', 'grid'], + ] as const)('uses focusgroup for arrow navigation in a %s', (layout, role, focusgroup) => { const { getByRole } = render( - + , ); - expect(getByRole('radiogroup')).toHaveAttribute('focusgroup', 'radiogroup'); + expect(getByRole(role)).toHaveAttribute('focusgroup', focusgroup); }); - it('moves focus through a grid with arrow keys and wraps', () => { - const { getByLabelText } = render( - - - - - - - - - - - - , - ); - const red = getByLabelText('Red'); - const yellow = getByLabelText('Yellow'); - const green = getByLabelText('Green'); - const violet = getByLabelText('Violet'); - - red.focus(); - expect(yellow).toHaveAttribute('tabindex', '-1'); - - fireEvent.keyDown(red, { key: 'ArrowRight' }); - expect(yellow).toHaveFocus(); - - fireEvent.keyDown(yellow, { key: 'ArrowDown' }); - expect(violet).toHaveFocus(); - - fireEvent.keyDown(violet, { key: 'ArrowRight' }); - expect(red).toHaveFocus(); - - fireEvent.keyDown(red, { key: 'ArrowUp' }); - expect(green).toHaveFocus(); - }); - - it('does not add grid arrow navigation in tab mode', () => { + it('does not add grid arrow navigation in tab mode', async () => { const onKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault()); const { getByLabelText } = render( @@ -85,7 +53,7 @@ describe('SwatchPicker', () => { const green = getByLabelText('Green'); red.focus(); - fireEvent.keyDown(red, { key: 'ArrowRight' }); + await userEvent.keyboard('{ArrowRight}'); expect(onKeyDown).toHaveBeenCalledTimes(1); expect(red).toHaveFocus(); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts index c11c76128712f..dd6b7a951c10e 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts @@ -4,6 +4,13 @@ export type { SwatchPickerBaseProps as SwatchPickerProps, SwatchPickerSlots } fr export type SwatchPickerState = SwatchPickerBaseState & { root: { + /** + * Whether SwatchPicker is row or grid + */ + 'data-layout'?: SwatchPickerBaseState['layout']; + /** + * Arrow navigation mode for SwatchPicker + */ focusgroup?: string; }; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts index 6358b8d832d03..3c7fef5905f52 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts @@ -10,13 +10,16 @@ export { } from '@fluentui/react-swatch-picker'; export const useSwatchPicker = (props: SwatchPickerProps, ref: React.Ref): SwatchPickerState => { - const { focusMode = 'arrow' } = props; + const { focusMode = 'arrow', layout = 'row' } = props; const baseState: SwatchPickerState = useSwatchPickerBase_unstable(props, ref); + // eslint-disable-next-line react-hooks/immutability + baseState.root['data-layout'] = layout; + if (focusMode === 'arrow') { // eslint-disable-next-line react-hooks/immutability - baseState.root['focusgroup'] = baseState.isGrid ? 'grid' : 'radiogroup'; + baseState.root.focusgroup = baseState.isGrid ? 'grid' : 'radiogroup'; } return baseState; diff --git a/packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts b/packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts index 5c4ab901489aa..e9b333fe83f85 100644 --- a/packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts +++ b/packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts @@ -1,53 +1,35 @@ export { - // SwatchPicker SwatchPicker, renderSwatchPicker, useSwatchPicker, useSwatchPickerContextValues, useSwatchPickerContextValue, - - // ColorSwatch ColorSwatch, renderColorSwatch, useColorSwatch, - - // EmptySwatch EmptySwatch, renderEmptySwatch, useEmptySwatch, - - // ImageSwatch ImageSwatch, renderImageSwatch, useImageSwatch, - - // SwatchPickerRow SwatchPickerRow, renderSwatchPickerRow, useSwatchPickerRow, } from './components/SwatchPicker'; export type { - // SwatchPicker SwatchPickerSlots, SwatchPickerProps, SwatchPickerState, - - // ColorSwatch ColorSwatchSlots, ColorSwatchProps, ColorSwatchState, - - // EmptySwatch EmptySwatchSlots, EmptySwatchProps, EmptySwatchState, - - // ImageSwatch ImageSwatchSlots, ImageSwatchProps, ImageSwatchState, - - // SwatchPickerRow SwatchPickerRowSlots, SwatchPickerRowProps, SwatchPickerRowState, diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md index 93b7deca1f283..1eee8aefdad7e 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md @@ -1,8 +1,7 @@ SwatchPicker provides a semantic radio group or grid of selectable color and image swatches. Use `ColorSwatch`, `ImageSwatch`, and `EmptySwatch` as children of `SwatchPicker`. Give the picker -an accessible name and give each swatch an accessible name. The headless components expose -`data-layout`, `data-focus-mode`, `data-selected`, and `data-disabled` for styling behavior. +an accessible name and give each swatch an accessible name. When `layout="grid"`, group swatches into `SwatchPickerRow` children. The row structure provides the grid semantics and coordinates used by arrow-key navigation; CSS controls only the visual layout. diff --git a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx index 95e26b5ab6491..825c6e85f6405 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx @@ -8,6 +8,7 @@ import { } from '@fluentui/react-headless-components-preview/swatch-picker'; import descriptionMd from './SwatchPickerDescription.md'; import styles from './swatch-picker.module.css'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; export { Default } from './SwatchPickerDefault.stories'; export { EmptySwatchExample } from './SwatchPickerEmpty.stories'; @@ -21,7 +22,7 @@ export default { parameters: { docs: { description: { - component: descriptionMd, + component: descriptionMd + getBrowserSupportNotice('SwatchPicker'), }, }, }, diff --git a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts index 14e13b74f40a3..6b5d52f6861a0 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts +++ b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts @@ -22,6 +22,7 @@ const COMPONENT_FEATURES: Record = { Toolbar: ['focusgroup'], TabList: ['focusgroup'], Nav: ['focusgroup'], + SwatchPicker: ['focusgroup'], }; const BROWSER_SUPPORT_DOCS_PATH = '?path=/docs/overview-browser-support--docs'; From ddf0ba763ddffdd27ea8bd863220c3a03ac31f10 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 7 Aug 2026 22:54:30 +0200 Subject: [PATCH 5/8] chore: update swatch picker API report --- .../library/etc/swatch-picker.api.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md index 1353ee85676e9..c77a0bbf0c5d1 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md @@ -6,14 +6,14 @@ import type { ColorSwatchBaseState } from '@fluentui/react-swatch-picker'; import { ColorSwatchBaseProps as ColorSwatchProps } from '@fluentui/react-swatch-picker'; -import type { ColorSwatchSlots } from '@fluentui/react-swatch-picker'; +import { ColorSwatchSlots } from '@fluentui/react-swatch-picker'; import type { EmptySwatchBaseState } from '@fluentui/react-swatch-picker'; import { EmptySwatchBaseProps as EmptySwatchProps } from '@fluentui/react-swatch-picker'; -import type { EmptySwatchSlots } from '@fluentui/react-swatch-picker'; +import { EmptySwatchSlots } from '@fluentui/react-swatch-picker'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { ImageSwatchBaseState } from '@fluentui/react-swatch-picker'; import { ImageSwatchBaseProps as ImageSwatchProps } from '@fluentui/react-swatch-picker'; -import type { ImageSwatchSlots as ImageSwatchSlots_2 } from '@fluentui/react-swatch-picker'; +import { ImageSwatchSlots } from '@fluentui/react-swatch-picker'; import type * as React_2 from 'react'; import { renderColorSwatch_unstable as renderColorSwatch } from '@fluentui/react-swatch-picker'; import { renderEmptySwatch_unstable as renderEmptySwatch } from '@fluentui/react-swatch-picker'; @@ -65,8 +65,7 @@ export const ImageSwatch: ForwardRefComponent; export { ImageSwatchProps } -// @public (undocumented) -export type ImageSwatchSlots = ImageSwatchSlots_2; +export { ImageSwatchSlots } // @public (undocumented) export type ImageSwatchState = ImageSwatchBaseState & { From d4383443901f096326fdda2d6bd9c1a61b575288 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Sat, 8 Aug 2026 15:00:00 +0200 Subject: [PATCH 6/8] update SwatchPicker focusgroup attributes --- .../library/etc/swatch-picker.api.md | 13 +++++++++---- .../SwatchPicker/SwatchPicker.cy.tsx | 2 +- .../SwatchPicker/SwatchPicker.test.tsx | 2 +- .../SwatchPickerRow/SwatchPickerRow.test.tsx | 5 ++++- .../SwatchPickerRow/SwatchPickerRow.types.ts | 12 +++++++++++- .../SwatchPickerRow/useSwatchPickerRow.ts | 18 +++++++++++++++++- .../components/SwatchPicker/useSwatchPicker.ts | 2 +- 7 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md index c77a0bbf0c5d1..d4f2f18cc1f36 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md @@ -22,13 +22,12 @@ import { renderSwatchPicker_unstable as renderSwatchPicker } from '@fluentui/rea import { renderSwatchPickerRow_unstable as renderSwatchPickerRow } from '@fluentui/react-swatch-picker'; import type { SwatchPickerBaseState } from '@fluentui/react-swatch-picker'; import { SwatchPickerBaseProps as SwatchPickerProps } from '@fluentui/react-swatch-picker'; +import type { SwatchPickerRowBaseState } from '@fluentui/react-swatch-picker'; import { SwatchPickerRowBaseProps as SwatchPickerRowProps } from '@fluentui/react-swatch-picker'; import { SwatchPickerRowSlots } from '@fluentui/react-swatch-picker'; -import { SwatchPickerRowBaseState as SwatchPickerRowState } from '@fluentui/react-swatch-picker'; import { SwatchPickerSlots } from '@fluentui/react-swatch-picker'; import { useSwatchPickerContextValue_unstable as useSwatchPickerContextValue } from '@fluentui/react-swatch-picker'; import { useSwatchPickerContextValues } from '@fluentui/react-swatch-picker'; -import { useSwatchPickerRowBase_unstable as useSwatchPickerRow } from '@fluentui/react-swatch-picker'; // @public (undocumented) export const ColorSwatch: ForwardRefComponent; @@ -96,7 +95,12 @@ export { SwatchPickerRowProps } export { SwatchPickerRowSlots } -export { SwatchPickerRowState } +// @public (undocumented) +export type SwatchPickerRowState = SwatchPickerRowBaseState & { + root: SwatchPickerRowBaseState['root'] & { + focusgrouprow?: string; + }; +}; export { SwatchPickerSlots } @@ -124,7 +128,8 @@ export { useSwatchPickerContextValue } export { useSwatchPickerContextValues } -export { useSwatchPickerRow } +// @public (undocumented) +export const useSwatchPickerRow: (props: SwatchPickerRowProps, ref: React_2.Ref) => SwatchPickerRowState; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx index c02564cb4afa0..e4ca3615af95a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx @@ -29,7 +29,7 @@ describe('SwatchPicker', () => { , ); - cy.get('[role="grid"]').should('have.attr', 'focusgroup', 'grid'); + cy.get('[role="grid"]').should('have.attr', 'focusgroup', 'grid manual rowflow'); cy.get('[role="grid"]').should('not.have.attr', 'data-tabster'); cy.get('[aria-label="Red"]').focus().realPress('ArrowRight'); cy.get('[aria-label="Yellow"]').should('be.focused').realPress('ArrowDown'); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx index ee54973b8679a..653838d5a1895 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx @@ -28,7 +28,7 @@ describe('SwatchPicker', () => { it.each([ ['row', 'radiogroup', 'radiogroup'], - ['grid', 'grid', 'grid'], + ['grid', 'grid', 'grid manual rowflow'], ] as const)('uses focusgroup for arrow navigation in a %s', (layout, role, focusgroup) => { const { getByRole } = render( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx index c68eeb1435fb6..1d0ab0d80a027 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx @@ -11,6 +11,9 @@ describe('SwatchPickerRow', () => { }); it('renders a row', () => { - expect(render(Colors).getByRole('row')).toHaveTextContent('Colors'); + const row = render(Colors).getByRole('row'); + + expect(row).toHaveTextContent('Colors'); + expect(row).toHaveAttribute('focusgrouprow'); }); }); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts index 629406474f9d3..4068d05ad1271 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts @@ -1,5 +1,15 @@ +import type { SwatchPickerRowBaseState } from '@fluentui/react-swatch-picker'; + export type { SwatchPickerRowBaseProps as SwatchPickerRowProps, SwatchPickerRowSlots, - SwatchPickerRowBaseState as SwatchPickerRowState, } from '@fluentui/react-swatch-picker'; + +export type SwatchPickerRowState = SwatchPickerRowBaseState & { + root: SwatchPickerRowBaseState['root'] & { + /** + * The `focusgrouprow` attribute is used to indicate that the row is part of a focus group for keyboard navigation. + */ + focusgrouprow?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts index ba5ae6dcfee5a..04a5d97d6773f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts @@ -1 +1,17 @@ -export { useSwatchPickerRowBase_unstable as useSwatchPickerRow } from '@fluentui/react-swatch-picker'; +'use client'; + +import type * as React from 'react'; +import { useSwatchPickerRowBase_unstable } from '@fluentui/react-swatch-picker'; +import type { SwatchPickerRowProps, SwatchPickerRowState } from './SwatchPickerRow.types'; + +export const useSwatchPickerRow = ( + props: SwatchPickerRowProps, + ref: React.Ref, +): SwatchPickerRowState => { + const state: SwatchPickerRowState = useSwatchPickerRowBase_unstable(props, ref); + + // eslint-disable-next-line react-hooks/immutability + state.root.focusgrouprow = ''; + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts index 3c7fef5905f52..16212bc1d8441 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts @@ -19,7 +19,7 @@ export const useSwatchPicker = (props: SwatchPickerProps, ref: React.Ref Date: Mon, 10 Aug 2026 17:41:21 +0200 Subject: [PATCH 7/8] disable grid navigation e2e test --- .../library/src/components/SwatchPicker/SwatchPicker.cy.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx index e4ca3615af95a..cd3e9c14ddb66 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx @@ -13,7 +13,8 @@ polyfillBodyAndObserve(); const mount = (element: JSXElement) => mountBase({element}); describe('SwatchPicker', () => { - it('moves focus through a grid with arrow keys and wraps', () => { + // TODO Enable this test once the focusgroup-polyfill is updated to support the new arrow key navigation behavior + it.skip('moves focus through a grid with arrow keys and wraps', () => { mount( From 86b6f2c792ef73c489f856035b61c0e7b4e4fe5e Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Mon, 10 Aug 2026 18:43:21 +0200 Subject: [PATCH 8/8] Update packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx Co-authored-by: Victor Genaev --- .../library/src/components/SwatchPicker/SwatchPicker.cy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx index cd3e9c14ddb66..4146058e23aa7 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx @@ -13,7 +13,7 @@ polyfillBodyAndObserve(); const mount = (element: JSXElement) => mountBase({element}); describe('SwatchPicker', () => { - // TODO Enable this test once the focusgroup-polyfill is updated to support the new arrow key navigation behavior + // TODO: Enable this test once the focusgroup-polyfill is updated to support the new arrow key navigation behavior it.skip('moves focus through a grid with arrow keys and wraps', () => { mount(