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 00000000000000..d9e0ca2793c457 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-931596e2-bc46-4837-a7cc-db12b9edd5cd.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: 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 e019dfd88c27a9..a300b981f7653c 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 00000000000000..af62ab6d3cab6f --- /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 5ea5c4f77d04af..c7c4dae5dd74d0 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 00000000000000..4fa7f3c469600a --- /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 00000000000000..20678a3304a614 --- /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 00000000000000..c41babeded986c --- /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 00000000000000..8114ede1931333 --- /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 00000000000000..16f0c344cf8090 --- /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 00000000000000..0a5be3d66b2f9a --- /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 00000000000000..2ac9429011c78f --- /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 00000000000000..0be33ea73d7cec --- /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 00000000000000..630684b102b907 --- /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 00000000000000..8a106691b6b562 --- /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 00000000000000..9099162e6159f6 --- /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 00000000000000..f6618a836f105d --- /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 00000000000000..9950a5d90a057d --- /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 00000000000000..c4af99e23b62cb --- /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 00000000000000..edeaf9517b539d --- /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 00000000000000..fb44b96d644b69 --- /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 00000000000000..c9a6d31f52513c --- /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 00000000000000..e73fc3309cfe28 --- /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 00000000000000..17a86e7eed40f8 --- /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 00000000000000..643bbfccc3d751 --- /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 00000000000000..c11c76128712fe --- /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 00000000000000..c68eeb1435fb60 --- /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 00000000000000..52f98c1786aee6 --- /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 00000000000000..629406474f9d32 --- /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 00000000000000..2354688677978e --- /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 00000000000000..b07e650b1c7346 --- /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 00000000000000..ba5ae6dcfee5a3 --- /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 00000000000000..444ead4b978909 --- /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 00000000000000..5d80a2ef99b8cb --- /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 00000000000000..509a75dd4d7bae --- /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 14bbafd814b9b7..9229e848eaca47 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 00000000000000..c07058645b6de4 --- /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 00000000000000..94a3135dc8e50b --- /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 00000000000000..b43153ec0f871a --- /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 00000000000000..5c4ab901489aa4 --- /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 00000000000000..46a00aa58f899c --- /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 00000000000000..93b7deca1f2834 --- /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 00000000000000..2ceec7eadd935c --- /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 00000000000000..2fc4738ec6278d --- /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 00000000000000..973d3b9e537025 --- /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 00000000000000..95e26b5ab64910 --- /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 00000000000000..10417aa37f8c76 --- /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 59dca6f0f0833c..ad1847e07e9e96 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"