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..d9e0ca2793c45 --- /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 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..d4f2f18cc1f36 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/swatch-picker.api.md @@ -0,0 +1,136 @@ +## 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 type { ColorSwatchBaseState } from '@fluentui/react-swatch-picker'; +import { ColorSwatchBaseProps as ColorSwatchProps } 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 { 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 { 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'; +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 type { SwatchPickerRowBaseState } from '@fluentui/react-swatch-picker'; +import { SwatchPickerRowBaseProps as SwatchPickerRowProps } from '@fluentui/react-swatch-picker'; +import { SwatchPickerRowSlots } 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'; + +// @public (undocumented) +export const ColorSwatch: ForwardRefComponent; + +export { ColorSwatchProps } + +export { ColorSwatchSlots } + +// @public (undocumented) +export type ColorSwatchState = ColorSwatchBaseState & { + root: { + 'data-selected'?: string; + 'data-disabled'?: string; + }; +}; + +// @public (undocumented) +export const EmptySwatch: ForwardRefComponent; + +export { EmptySwatchProps } + +export { EmptySwatchSlots } + +// @public (undocumented) +export type EmptySwatchState = EmptySwatchBaseState & { + root: { + 'data-selected'?: string; + 'data-disabled'?: string; + }; +}; + +// @public (undocumented) +export const ImageSwatch: ForwardRefComponent; + +export { ImageSwatchProps } + +export { ImageSwatchSlots } + +// @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 } + +// @public (undocumented) +export type SwatchPickerRowState = SwatchPickerRowBaseState & { + root: SwatchPickerRowBaseState['root'] & { + focusgrouprow?: string; + }; +}; + +export { SwatchPickerSlots } + +// @public (undocumented) +export type SwatchPickerState = SwatchPickerBaseState & { + root: { + 'data-layout'?: SwatchPickerBaseState['layout']; + focusgroup?: string; + }; +}; + +// @public (undocumented) +export const useColorSwatch: (props: ColorSwatchProps, ref: React_2.Ref) => ColorSwatchState; + +// @public (undocumented) +export const useEmptySwatch: (props: EmptySwatchProps, ref: React_2.Ref) => EmptySwatchState; + +// @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 } + +// @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/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..3ada7f41c6b38 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.test.tsx @@ -0,0 +1,28 @@ +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'); + 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.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..7505a6bcdac7f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/ColorSwatch.types.ts @@ -0,0 +1,16 @@ +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/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..055de0d123e37 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ColorSwatch/useColorSwatch.ts @@ -0,0 +1,17 @@ +'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 new file mode 100644 index 0000000000000..ee7adf43040ff --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.test.tsx @@ -0,0 +1,16 @@ +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 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.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..60e97faf83298 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/EmptySwatch.types.ts @@ -0,0 +1,16 @@ +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/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..20c685ba560a7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/EmptySwatch/useEmptySwatch.ts @@ -0,0 +1,17 @@ +'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 new file mode 100644 index 0000000000000..8e6f342d5933c --- /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..5accc268e485a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/ImageSwatch/ImageSwatch.types.ts @@ -0,0 +1,12 @@ +import type { ImageSwatchBaseState } from '@fluentui/react-swatch-picker'; + +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/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.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx new file mode 100644 index 0000000000000..4146058e23aa7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.cy.tsx @@ -0,0 +1,40 @@ +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', () => { + // 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( + + + + + + + + + + + + , + ); + + 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'); + 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 new file mode 100644 index 0000000000000..653838d5a1895 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.test.tsx @@ -0,0 +1,62 @@ +import * as React from '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'; +import { SwatchPickerRow } from './SwatchPickerRow/SwatchPickerRow'; + +describe('SwatchPicker', () => { + isConformant({ Component: SwatchPicker, displayName: 'SwatchPicker' }); + + it('renders semantic state attributes and forwards selection', async () => { + const onSelectionChange = jest.fn(); + const { getByRole } = render( + + + , + ); + + expect(getByRole('grid')).toHaveAttribute('data-layout', 'grid'); + + await userEvent.click(getByRole('gridcell')); + expect(onSelectionChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ selectedValue: 'pink', selectedSwatch: '#f09' }), + ); + }); + + it.each([ + ['row', 'radiogroup', 'radiogroup'], + ['grid', 'grid', 'grid manual rowflow'], + ] as const)('uses focusgroup for arrow navigation in a %s', (layout, role, focusgroup) => { + const { getByRole } = render( + + + , + ); + + expect(getByRole(role)).toHaveAttribute('focusgroup', focusgroup); + }); + + it('does not add grid arrow navigation in tab mode', async () => { + const onKeyDown = jest.fn((event: React.KeyboardEvent) => event.preventDefault()); + const { getByLabelText } = render( + + + + + + , + ); + const red = getByLabelText('Red'); + const green = getByLabelText('Green'); + + red.focus(); + await userEvent.keyboard('{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..dd6b7a951c10e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPicker.types.ts @@ -0,0 +1,16 @@ +import type { SwatchPickerBaseState } from '@fluentui/react-swatch-picker'; + +export type { SwatchPickerBaseProps as SwatchPickerProps, SwatchPickerSlots } from '@fluentui/react-swatch-picker'; + +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/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..1d0ab0d80a027 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.test.tsx @@ -0,0 +1,19 @@ +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', () => { + 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.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..4068d05ad1271 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/SwatchPickerRow.types.ts @@ -0,0 +1,15 @@ +import type { SwatchPickerRowBaseState } from '@fluentui/react-swatch-picker'; + +export type { + SwatchPickerRowBaseProps as SwatchPickerRowProps, + SwatchPickerRowSlots, +} 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/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..04a5d97d6773f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/SwatchPickerRow/useSwatchPickerRow.ts @@ -0,0 +1,17 @@ +'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/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..16212bc1d8441 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/SwatchPicker/useSwatchPicker.ts @@ -0,0 +1,26 @@ +'use client'; + +import type * as React from 'react'; +import { useSwatchPickerBase_unstable } from '@fluentui/react-swatch-picker'; +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', 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 manual rowflow' : '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 new file mode 100644 index 0000000000000..e9b333fe83f85 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/swatch-picker.ts @@ -0,0 +1,36 @@ +export { + SwatchPicker, + renderSwatchPicker, + useSwatchPicker, + useSwatchPickerContextValues, + useSwatchPickerContextValue, + ColorSwatch, + renderColorSwatch, + useColorSwatch, + EmptySwatch, + renderEmptySwatch, + useEmptySwatch, + ImageSwatch, + renderImageSwatch, + useImageSwatch, + SwatchPickerRow, + renderSwatchPickerRow, + useSwatchPickerRow, +} from './components/SwatchPicker'; +export type { + SwatchPickerSlots, + SwatchPickerProps, + SwatchPickerState, + ColorSwatchSlots, + ColorSwatchProps, + ColorSwatchState, + EmptySwatchSlots, + EmptySwatchProps, + EmptySwatchState, + ImageSwatchSlots, + ImageSwatchProps, + ImageSwatchState, + 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..1eee8aefdad7e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/SwatchPickerDescription.md @@ -0,0 +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. + +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..825c6e85f6405 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/SwatchPicker/index.stories.tsx @@ -0,0 +1,36 @@ +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'; +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + +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 + getBrowserSupportNotice('SwatchPicker'), + }, + }, + }, + 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/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'; 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"