From efbc54d45f67039dd6baa557c80f9c970ce6d0f6 Mon Sep 17 00:00:00 2001 From: Victor Genaev Date: Fri, 7 Aug 2026 22:21:54 +0200 Subject: [PATCH 1/5] feat(react-headless-components-preview): add CompoundButton (#36531) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-e472bfdc-9096-4c80-8ab2-7c1fe543cdde.json | 7 + .../bundle-size/AllComponents.fixture.js | 2 + .../library/etc/compound-button.api.md | 38 +++ .../library/package.json | 6 + .../CompoundButton/CompoundButton.test.tsx | 231 ++++++++++++++++++ .../CompoundButton/CompoundButton.tsx | 19 ++ .../CompoundButton/CompoundButton.types.ts | 30 +++ .../src/components/CompoundButton/index.ts | 4 + .../CompoundButton/renderCompoundButton.ts | 1 + .../CompoundButton/useCompoundButton.ts | 29 +++ .../library/src/compound-button.ts | 2 + .../CompoundButtonDefault.stories.tsx | 89 +++++++ .../CompoundButtonDescription.md | 16 ++ .../CompoundButton/compound-button.module.css | 153 ++++++++++++ .../src/CompoundButton/index.stories.tsx | 17 ++ 15 files changed, 644 insertions(+) create mode 100644 change/@fluentui-react-headless-components-preview-e472bfdc-9096-4c80-8ab2-7c1fe543cdde.json create mode 100644 packages/react-components/react-headless-components-preview/library/etc/compound-button.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/renderCompoundButton.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/useCompoundButton.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/compound-button.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/CompoundButton/CompoundButtonDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/CompoundButton/CompoundButtonDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/CompoundButton/compound-button.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/CompoundButton/index.stories.tsx diff --git a/change/@fluentui-react-headless-components-preview-e472bfdc-9096-4c80-8ab2-7c1fe543cdde.json b/change/@fluentui-react-headless-components-preview-e472bfdc-9096-4c80-8ab2-7c1fe543cdde.json new file mode 100644 index 0000000000000..e99c088b374cd --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-e472bfdc-9096-4c80-8ab2-7c1fe543cdde.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add headless CompoundButton", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.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..3485a0285aa9e 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 @@ -7,6 +7,7 @@ import * as Button from '@fluentui/react-headless-components-preview/button'; import * as Card from '@fluentui/react-headless-components-preview/card'; import * as Checkbox from '@fluentui/react-headless-components-preview/checkbox'; import * as Combobox from '@fluentui/react-headless-components-preview/combobox'; +import * as CompoundButton from '@fluentui/react-headless-components-preview/compound-button'; import * as Dialog from '@fluentui/react-headless-components-preview/dialog'; import * as Divider from '@fluentui/react-headless-components-preview/divider'; import * as Drawer from '@fluentui/react-headless-components-preview/drawer'; @@ -57,6 +58,7 @@ console.log({ Card, Checkbox, Combobox, + CompoundButton, Dialog, Divider, Drawer, diff --git a/packages/react-components/react-headless-components-preview/library/etc/compound-button.api.md b/packages/react-components/react-headless-components-preview/library/etc/compound-button.api.md new file mode 100644 index 0000000000000..a173707e89ebc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/compound-button.api.md @@ -0,0 +1,38 @@ +## 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 { CompoundButtonBaseState } from '@fluentui/react-button'; +import { CompoundButtonBaseProps as CompoundButtonProps } from '@fluentui/react-button'; +import { CompoundButtonSlots } from '@fluentui/react-button'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type * as React_2 from 'react'; +import { renderCompoundButton_unstable as renderCompoundButton } from '@fluentui/react-button'; + +// @public +export const CompoundButton: ForwardRefComponent; + +export { CompoundButtonProps } + +export { CompoundButtonSlots } + +// @public +export type CompoundButtonState = CompoundButtonBaseState & { + root: { + 'data-disabled'?: string; + 'data-disabled-focusable'?: string; + 'data-icon-only'?: string; + 'data-has-secondary-content'?: string; + }; +}; + +export { renderCompoundButton } + +// @public +export const useCompoundButton: (props: CompoundButtonProps, ref: React_2.Ref) => CompoundButtonState; + +// (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..718fb2bcb0919 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -130,6 +130,12 @@ "import": "./lib/combobox.js", "require": "./lib-commonjs/combobox.js" }, + "./compound-button": { + "types": "./dist/compound-button.d.ts", + "node": "./lib-commonjs/compound-button.js", + "import": "./lib/compound-button.js", + "require": "./lib-commonjs/compound-button.js" + }, "./dialog": { "types": "./dist/dialog.d.ts", "node": "./lib-commonjs/dialog.js", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.test.tsx new file mode 100644 index 0000000000000..4c313a0857ef0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.test.tsx @@ -0,0 +1,231 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { isConformant } from '../../testing/isConformant'; +import { CompoundButton } from './CompoundButton'; + +describe('CompoundButton', () => { + isConformant({ + Component: CompoundButton, + displayName: 'CompoundButton', + }); + + it('renders a button with primary and secondary text in its accessible name', () => { + const { getByRole } = render(Primary); + const button = getByRole('button', { name: 'Primary Secondary' }); + + expect(button).toHaveAttribute('type', 'button'); + }); + + it('supports anchor polymorphism and href', () => { + const { getByRole } = render( + + Link + , + ); + + expect(getByRole('link', { name: 'Link' })).toHaveAttribute('href', '#compound-button'); + }); + + it('forwards root className and style', () => { + const { getByRole } = render( + + Button + , + ); + + expect(getByRole('button')).toHaveClass('root-class'); + expect(getByRole('button')).toHaveStyle({ cursor: 'pointer' }); + }); + + it('forwards icon, contentContainer, and secondaryContent slot props', () => { + const { container } = render( + + Primary + , + ); + + expect(container.querySelector('.icon-class')).toHaveTextContent('Icon'); + expect(container.querySelector('.content-class')).toHaveTextContent('PrimarySecondary'); + expect(container.querySelector('.secondary-class')).toHaveTextContent('Secondary'); + }); + + it.each([ + ['before', 'icon', 'content'], + ['after', 'content', 'icon'], + ] as const)('renders an icon %s the content', (iconPosition, first, last) => { + const { container, getByRole } = render( + + Primary + , + ); + const button = getByRole('button'); + + expect(button.firstElementChild).toBe(container.querySelector(`.${first}`)); + expect(button.lastElementChild).toBe(container.querySelector(`.${last}`)); + }); + + it('renders secondary content without primary content', () => { + const { getByRole } = render(); + + expect(getByRole('button', { name: 'Secondary' })).toBeInTheDocument(); + }); + + it('blocks activation and emits an exact disabled presence attribute when disabled', () => { + const onClick = jest.fn(); + const { getByRole } = render( + + Disabled + , + ); + const button = getByRole('button'); + + fireEvent.click(button); + + expect(onClick).not.toHaveBeenCalled(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('data-disabled', ''); + }); + + it('remains focusable, blocks activation, and emits an exact attribute when disabledFocusable', () => { + const onClick = jest.fn(); + const { getByRole } = render( + + Disabled focusable + , + ); + const button = getByRole('button'); + + button.focus(); + fireEvent.click(button); + + expect(button).toHaveFocus(); + expect(button).not.toBeDisabled(); + expect(button).toHaveAttribute('aria-disabled', 'true'); + expect(button).toHaveAttribute('data-disabled-focusable', ''); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('allows focus but suppresses Enter and Space activation when disabledFocusable', () => { + const onClick = jest.fn(); + const { getByRole } = render( + + Disabled focusable + , + ); + const button = getByRole('button'); + + button.focus(); + userEvent.keyboard('{Enter}'); + userEvent.keyboard('{space}'); + + expect(button).toHaveFocus(); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('emits an exact icon-only presence attribute', () => { + const { getByRole } = render(); + + expect(getByRole('button')).toHaveAttribute('data-icon-only', ''); + }); + + it('secondary content prevents icon-only state', () => { + const { getByRole } = render( + + Primary + , + ); + const button = getByRole('button'); + + expect(button).not.toHaveAttribute('data-icon-only'); + expect(button).toHaveAttribute('data-has-secondary-content', ''); + }); + + it('marks string secondary content as rendered', () => { + const { getByRole } = render(Primary); + + expect(getByRole('button')).toHaveAttribute('data-has-secondary-content', ''); + }); + + it('marks React element secondary content as rendered', () => { + const { getByRole } = render(Secondary}>Primary); + + expect(getByRole('button')).toHaveAttribute('data-has-secondary-content', ''); + }); + + it('marks a secondary slot object with children as rendered', () => { + const { getByRole } = render(Primary); + + expect(getByRole('button')).toHaveAttribute('data-has-secondary-content', ''); + }); + + it('keeps an icon-only button when the secondary slot object is empty', () => { + const { getByRole } = render(); + const button = getByRole('button'); + + expect(button).toHaveAttribute('data-icon-only', ''); + expect(button).not.toHaveAttribute('data-has-secondary-content'); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ] as const)('omits has-secondary-content for %s secondary content', (_name, secondaryContent) => { + const { getByRole } = render(Primary); + + expect(getByRole('button')).not.toHaveAttribute('data-has-secondary-content'); + }); + + it('overwrites conflicting reserved attributes for present states', () => { + const { getByRole } = render( + <> + + + Primary + + , + ); + const button = getByRole('button', { name: 'Reserved attributes' }); + const buttonWithSecondaryContent = getByRole('button', { name: 'Primary Secondary' }); + + expect(button).toHaveAttribute('data-disabled', ''); + expect(button).toHaveAttribute('data-disabled-focusable', ''); + expect(button).toHaveAttribute('data-icon-only', ''); + expect(buttonWithSecondaryContent).toHaveAttribute('data-has-secondary-content', ''); + }); + + it('removes conflicting reserved attributes for absent states', () => { + const { getByRole } = render( + + Primary + , + ); + const button = getByRole('button'); + + expect(button).not.toHaveAttribute('data-disabled'); + expect(button).not.toHaveAttribute('data-disabled-focusable'); + expect(button).not.toHaveAttribute('data-icon-only'); + expect(button).not.toHaveAttribute('data-has-secondary-content'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.tsx new file mode 100644 index 0000000000000..0f99de5ba312a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { CompoundButtonProps } from './CompoundButton.types'; +import { renderCompoundButton } from './renderCompoundButton'; +import { useCompoundButton } from './useCompoundButton'; + +/** + * A button with primary and secondary content. Concise primary and secondary text form its accessible name. + */ +export const CompoundButton: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCompoundButton(props, ref); + + return renderCompoundButton(state); +}); + +CompoundButton.displayName = 'CompoundButton'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.types.ts new file mode 100644 index 0000000000000..50c367ba493c3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/CompoundButton.types.ts @@ -0,0 +1,30 @@ +import type { CompoundButtonBaseState } from '@fluentui/react-button'; + +export type { CompoundButtonBaseProps as CompoundButtonProps, CompoundButtonSlots } from '@fluentui/react-button'; + +/** + * CompoundButton component state. + */ +export type CompoundButtonState = CompoundButtonBaseState & { + root: { + /** + * Data attribute set when the button is disabled. + */ + 'data-disabled'?: string; + + /** + * Data attribute set when the button is disabled but still focusable. + */ + 'data-disabled-focusable'?: string; + + /** + * Data attribute set when the button renders only an icon. + */ + 'data-icon-only'?: string; + + /** + * Data attribute set when secondary content is rendered. + */ + 'data-has-secondary-content'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/index.ts new file mode 100644 index 0000000000000..c3472c6e0bc91 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/index.ts @@ -0,0 +1,4 @@ +export { CompoundButton } from './CompoundButton'; +export type { CompoundButtonProps, CompoundButtonSlots, CompoundButtonState } from './CompoundButton.types'; +export { renderCompoundButton } from './renderCompoundButton'; +export { useCompoundButton } from './useCompoundButton'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/renderCompoundButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/renderCompoundButton.ts new file mode 100644 index 0000000000000..76394ace4f094 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/renderCompoundButton.ts @@ -0,0 +1 @@ +export { renderCompoundButton_unstable as renderCompoundButton } from '@fluentui/react-button'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/useCompoundButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/useCompoundButton.ts new file mode 100644 index 0000000000000..878c5edbc7a6a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/CompoundButton/useCompoundButton.ts @@ -0,0 +1,29 @@ +'use client'; + +import type * as React from 'react'; +import { useCompoundButtonBase_unstable } from '@fluentui/react-button'; + +import { stringifyDataAttribute } from '../../utils'; +import type { CompoundButtonProps, CompoundButtonState } from './CompoundButton.types'; + +/** + * Returns the state for a CompoundButton component, given its props and ref. + */ +export const useCompoundButton = ( + props: CompoundButtonProps, + ref: React.Ref, +): CompoundButtonState => { + const state: CompoundButtonState = useCompoundButtonBase_unstable(props, ref); + const hasSecondaryContent = Boolean(!state.iconOnly && state.secondaryContent); + + // eslint-disable-next-line react-hooks/immutability + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + // eslint-disable-next-line react-hooks/immutability + state.root['data-disabled-focusable'] = stringifyDataAttribute(state.disabledFocusable); + // eslint-disable-next-line react-hooks/immutability + state.root['data-icon-only'] = stringifyDataAttribute(state.iconOnly); + // eslint-disable-next-line react-hooks/immutability + state.root['data-has-secondary-content'] = stringifyDataAttribute(hasSecondaryContent); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/compound-button.ts b/packages/react-components/react-headless-components-preview/library/src/compound-button.ts new file mode 100644 index 0000000000000..cfeb2db8705e3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/compound-button.ts @@ -0,0 +1,2 @@ +export { CompoundButton, renderCompoundButton, useCompoundButton } from './components/CompoundButton'; +export type { CompoundButtonProps, CompoundButtonSlots, CompoundButtonState } from './components/CompoundButton'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/CompoundButtonDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/CompoundButtonDefault.stories.tsx new file mode 100644 index 0000000000000..a00146d3c6768 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/CompoundButtonDefault.stories.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { CompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; +import { AddRegular, ArrowDownloadRegular, DeleteRegular, OpenRegular, SettingsRegular } from '@fluentui/react-icons'; + +import styles from './compound-button.module.css'; + +export const Default = (): React.ReactNode => { + const [completedActions, setCompletedActions] = React.useState(0); + + const iconSlot = (icon: React.ReactNode) => ({ + children: icon, + className: styles.actionIcon, + }); + + const contentContainerSlot = { + className: styles.actionContent, + }; + + const secondaryContentSlot = (children: React.ReactNode) => ({ + children, + className: styles.actionSecondary, + }); + + return ( +
+
+

Deployment workspace

+

Compound actions

+

+ One semantic component, styled here as a compact action system with CSS Modules. +

+
+ + + Actions completed: {completedActions} + + +
+ )} + contentContainer={contentContainerSlot} + secondaryContent={secondaryContentSlot('Starts a new deployment')} + onClick={() => setCompletedActions(value => value + 1)} + > + Create release + + + )} + contentContainer={contentContainerSlot} + secondaryContent={secondaryContentSlot('Opens release details')} + > + Review release + + + )} + contentContainer={contentContainerSlot} + secondaryContent={secondaryContentSlot('Unavailable while deployment is active')} + disabled + onClick={() => setCompletedActions(value => value + 1)} + > + Delete release + + + )} + contentContainer={contentContainerSlot} + secondaryContent={secondaryContentSlot('Focusable for feature discovery')} + disabledFocusable + > + Configure policy + + + )} + aria-label="Add deployment target" + /> +
+
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/CompoundButtonDescription.md b/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/CompoundButtonDescription.md new file mode 100644 index 0000000000000..261a61aa38a4e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/CompoundButtonDescription.md @@ -0,0 +1,16 @@ +A compound button presents a primary action with optional secondary text that adds concise context. Both text lines contribute to the accessible name, so keep them brief and avoid repeating the same information. + +`CompoundButton` renders a native button by default and can render an anchor with `as="a"` and `href`. A disabled native button uses the `disabled` attribute and cannot be focused or activated. A disabled anchor cannot use a native disabled attribute, so it exposes `aria-disabled="true"`, removes `href`, blocks activation, and still emits the root `data-disabled` attribute. Disabled native buttons also emit root `data-disabled`. A disabled-focusable button remains in the tab order, exposes `aria-disabled="true"`, and blocks activation. + +The `contentContainer` slot groups the primary and secondary text, while `secondaryContent` represents the second textual line. These semantic slots let consumers apply their own layout and typography without changing the component's behavior. + +## State attributes + +State attributes are applied to the root, omitted when false, and form the supported CSS targeting contract. + +| Attribute | Meaning | +| ---------------------------- | ---------------------------------------------------------------------------- | +| `data-disabled` | Disabled prop/state is active | +| `data-disabled-focusable` | Disabled-focusable state active | +| `data-icon-only` | Only icon content rendered | +| `data-has-secondary-content` | Normalized secondary-content slot is present and the button is not icon-only | diff --git a/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/compound-button.module.css b/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/compound-button.module.css new file mode 100644 index 0000000000000..36051afcac3ca --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/compound-button.module.css @@ -0,0 +1,153 @@ +.demo { + display: grid; + gap: var(--space-6); + padding: var(--space-6); + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-2xl); + background: var(--bg-soft); + color: var(--text); + box-shadow: var(--shadow-2); +} + +.introduction { + display: grid; + gap: var(--space-2); +} + +.eyebrow, +.heading, +.summary { + margin: 0; +} + +.eyebrow { + color: var(--accent); + font-family: var(--font-sans); + letter-spacing: var(--tracking-heading); +} + +.heading { + color: var(--text); + font-family: var(--font-display); + letter-spacing: var(--tracking-display); +} + +.summary { + color: var(--text-muted); +} + +.status { + justify-self: start; + padding-block: var(--space-2); + padding-inline: var(--space-3); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-pill); + background: var(--bg-elev); + color: var(--text); + font-family: var(--font-mono); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: var(--space-3); +} + +.action { + display: inline-grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: var(--space-3); + min-block-size: var(--space-16); + min-inline-size: calc(var(--space-16) + var(--space-16) + var(--space-16)); + padding-block: var(--space-3); + padding-inline: var(--space-4); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-lg); + background: var(--bg-elev); + color: var(--text); + box-shadow: var(--shadow-1); + cursor: pointer; + text-align: start; + text-decoration: none; +} + +.action:hover:not(:disabled, [data-disabled], [data-disabled-focusable], [aria-disabled='true']) { + border-color: var(--accent); + background: var(--accent-soft); +} + +.action:active:not(:disabled, [data-disabled], [data-disabled-focusable], [aria-disabled='true']) { + border-color: var(--accent-strong); + box-shadow: none; +} + +.action:focus-visible { + outline: var(--stroke-thick) solid var(--accent); + outline-offset: var(--space-1); +} + +.action:disabled, +.action[data-disabled], +.action[data-disabled-focusable], +.action[aria-disabled='true'] { + border-color: var(--border); + background: var(--surface-muted); + color: var(--text-faint); + box-shadow: none; + cursor: not-allowed; +} + +.action[data-disabled-focusable]:focus-visible, +.action[aria-disabled='true']:focus-visible { + outline-color: var(--accent); +} + +.actionIcon { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: var(--space-6); + block-size: var(--space-6); + color: var(--accent); +} + +.actionIcon > svg { + inline-size: var(--space-5); + block-size: var(--space-5); +} + +.actionContent { + display: grid; + gap: var(--space-1); + min-inline-size: 0; + color: inherit; +} + +.actionSecondary { + color: var(--text-muted); +} + +.action:disabled .actionIcon, +.action[data-disabled] .actionIcon, +.action[data-disabled-focusable] .actionIcon, +.action[aria-disabled='true'] .actionIcon, +.action:disabled .actionSecondary, +.action[data-disabled] .actionSecondary, +.action[data-disabled-focusable] .actionSecondary, +.action[aria-disabled='true'] .actionSecondary { + color: inherit; +} + +.anchorAction { + border-color: var(--accent); +} + +.iconAction[data-icon-only] { + display: inline-flex; + min-inline-size: var(--space-16); + inline-size: var(--space-16); + padding: 0; + justify-content: center; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/index.stories.tsx new file mode 100644 index 0000000000000..42be7f8c2092b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/CompoundButton/index.stories.tsx @@ -0,0 +1,17 @@ +import { CompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; + +import './compound-button.module.css'; +import descriptionMd from './CompoundButtonDescription.md'; +export { Default } from './CompoundButtonDefault.stories'; + +export default { + title: 'Components/CompoundButton', + component: CompoundButton, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; From 091c5b3719446c735ef80dcf9f53ac97a9eb67ec Mon Sep 17 00:00:00 2001 From: Victor Genaev Date: Fri, 7 Aug 2026 22:28:39 +0200 Subject: [PATCH 2/5] test(react-button): add SplitButton regression test before base-hook extraction (#36523) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-9244ff4e-e1f6-48df-a3fb-1d459975c232.json | 7 ++++ .../SplitButton/SplitButton.test.tsx | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 change/@fluentui-react-button-9244ff4e-e1f6-48df-a3fb-1d459975c232.json diff --git a/change/@fluentui-react-button-9244ff4e-e1f6-48df-a3fb-1d459975c232.json b/change/@fluentui-react-button-9244ff4e-e1f6-48df-a3fb-1d459975c232.json new file mode 100644 index 0000000000000..30194908b3c91 --- /dev/null +++ b/change/@fluentui-react-button-9244ff4e-e1f6-48df-a3fb-1d459975c232.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "test: add SplitButton regression coverage for default menu icon and slot element metadata", + "packageName": "@fluentui/react-button", + "email": "vgenaev@gmail.com", + "dependentChangeType": "none" +} diff --git a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx index 0b0401b59ef0d..641b1502cb376 100644 --- a/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx +++ b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx @@ -1,8 +1,13 @@ import * as React from 'react'; import { render } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; import userEvent from '@testing-library/user-event'; +import { isSlot, SLOT_ELEMENT_TYPE_SYMBOL } from '@fluentui/react-utilities'; import { isConformant } from '../../testing/isConformant'; import { SplitButton } from './SplitButton'; +import { Button } from '../Button/Button'; +import { MenuButton } from '../MenuButton/MenuButton'; +import { useSplitButton_unstable } from './useSplitButton'; import type { SplitButtonProps } from './SplitButton.types'; describe('SplitButton', () => { @@ -176,4 +181,35 @@ describe('SplitButton', () => { userEvent.click(primaryActionButton); expect(onClick).not.toHaveBeenCalled(); }); + + it('renders the default styled menu icon (chevron) when no menu icon is provided', () => { + const { getAllByRole } = render(This is a button); + const [, menuButton] = getAllByRole('button'); + + expect(menuButton.querySelector('svg')).toBeTruthy(); + }); + + it('renders an explicit menu icon instead of the default chevron', () => { + const { getAllByRole, getByText } = render(This is a button); + const [, menuButton] = getAllByRole('button'); + + expect(getByText('Test MenuIcon')).toBeTruthy(); + expect(menuButton.querySelector('svg')).toBeFalsy(); + }); + + it('resolves child slot element metadata to the styled Button and MenuButton components', () => { + const { result } = renderHook(() => useSplitButton_unstable({ children: 'This is a button' }, React.createRef())); + + const { primaryActionButton, menuButton } = result.current; + + expect(isSlot(primaryActionButton)).toBe(true); + expect(isSlot(menuButton)).toBe(true); + + if (!isSlot(primaryActionButton) || !isSlot(menuButton)) { + return; + } + + expect(primaryActionButton[SLOT_ELEMENT_TYPE_SYMBOL]).toBe(Button); + expect(menuButton[SLOT_ELEMENT_TYPE_SYMBOL]).toBe(MenuButton); + }); }); From 05e3a7e5fa6ede4aa60cb5b0eff46a0625455cd8 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 7 Aug 2026 22:53:08 +0200 Subject: [PATCH 3/5] test(react-color-picker): add hook state coverage (#36527) --- .../AlphaSlider/useAlphaSlider.test.tsx | 46 +++++++++++++++++++ .../ColorArea/useColorArea.test.tsx | 46 +++++++++++++++++++ .../ColorPicker/useColorPicker.test.tsx | 31 +++++++++++++ .../ColorSlider/useColorSlider.test.tsx | 46 +++++++++++++++++++ 4 files changed, 169 insertions(+) create mode 100644 packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.test.tsx create mode 100644 packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.test.tsx create mode 100644 packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.test.tsx create mode 100644 packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.test.tsx diff --git a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.test.tsx b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.test.tsx new file mode 100644 index 0000000000000..eb9e7bbbbc6f7 --- /dev/null +++ b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.test.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useAlphaSlider_unstable } from './useAlphaSlider'; +import { ColorPickerProvider } from '../../contexts/colorPicker'; + +describe('useAlphaSlider', () => { + it('uses the default shape', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useAlphaSlider_unstable({}, ref)); + + expect(result.current.shape).toBe('rounded'); + }); + + it('uses the shape from context', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useAlphaSlider_unstable({}, ref), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + expect(result.current.shape).toBe('square'); + }); + + it('prefers the shape prop over context', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useAlphaSlider_unstable({ shape: 'rounded' }, ref), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + expect(result.current.shape).toBe('rounded'); + }); + + it('uses the shape prop', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useAlphaSlider_unstable({ shape: 'square' }, ref)); + + expect(result.current.shape).toBe('square'); + }); +}); diff --git a/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.test.tsx b/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.test.tsx new file mode 100644 index 0000000000000..2fa02126c956a --- /dev/null +++ b/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.test.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { ColorPickerProvider } from '../../contexts/colorPicker'; +import { useColorArea_unstable } from './useColorArea'; + +describe('useColorArea', () => { + it('uses the default shape', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useColorArea_unstable({}, ref)); + + expect(result.current.shape).toBe('rounded'); + }); + + it('uses the shape from context', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useColorArea_unstable({}, ref), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + expect(result.current.shape).toBe('square'); + }); + + it('prefers the shape prop over context', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useColorArea_unstable({ shape: 'rounded' }, ref), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + expect(result.current.shape).toBe('rounded'); + }); + + it('uses the shape prop', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useColorArea_unstable({ shape: 'square' }, ref)); + + expect(result.current.shape).toBe('square'); + }); +}); diff --git a/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.test.tsx b/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.test.tsx new file mode 100644 index 0000000000000..bb0432940f4bd --- /dev/null +++ b/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.test.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useColorPicker_unstable } from './useColorPicker'; + +describe('useColorPicker', () => { + it('returns state based on props', () => { + const color = { h: 120, s: 0.5, v: 0.75 }; + const ref = React.createRef(); + const { result } = renderHook(() => useColorPicker_unstable({ color, shape: 'square' }, ref)); + + expect(result.current.color).toBe(color); + expect(result.current.shape).toBe('square'); + }); + + it('forwards requested color changes', () => { + const onColorChange = jest.fn(); + const color = { h: 240, s: 0.25, v: 0.5 }; + const event = {} as React.ChangeEvent; + const ref = React.createRef(); + const { result } = renderHook(() => useColorPicker_unstable({ onColorChange }, ref)); + + act(() => result.current.requestChange(event, { color })); + + expect(onColorChange).toHaveBeenCalledTimes(1); + expect(onColorChange).toHaveBeenCalledWith(event, { + type: 'change', + event, + color, + }); + }); +}); diff --git a/packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.test.tsx b/packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.test.tsx new file mode 100644 index 0000000000000..95f52ffc83e1a --- /dev/null +++ b/packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.test.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { ColorPickerProvider } from '../../contexts/colorPicker'; +import { useColorSlider_unstable } from './useColorSlider'; + +describe('useColorSlider', () => { + it('uses the default shape', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useColorSlider_unstable({}, ref)); + + expect(result.current.shape).toBe('rounded'); + }); + + it('uses the shape from context', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useColorSlider_unstable({}, ref), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + expect(result.current.shape).toBe('square'); + }); + + it('prefers the shape prop over context', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useColorSlider_unstable({ shape: 'rounded' }, ref), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }); + + expect(result.current.shape).toBe('rounded'); + }); + + it('uses the shape prop', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useColorSlider_unstable({ shape: 'square' }, ref)); + + expect(result.current.shape).toBe('square'); + }); +}); From 8f1ae4d7b51af9117121c9e1cb5ee8397c36b78e Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 7 Aug 2026 22:53:09 +0200 Subject: [PATCH 4/5] feat(react-color-picker): expose headless base APIs (#36517) --- ...-414d04c8-72e8-4708-83a7-466c4d885578.json | 7 ++ .../library/etc/react-color-picker.api.md | 68 +++++++++++++++++-- .../library/src/AlphaSlider.ts | 9 ++- .../library/src/ColorArea.ts | 3 + .../library/src/ColorPicker.ts | 5 ++ .../library/src/ColorSlider.ts | 4 ++ .../AlphaSlider/AlphaSlider.types.ts | 10 +++ .../src/components/AlphaSlider/index.ts | 10 ++- .../AlphaSlider/renderAlphaSlider.tsx | 4 +- .../components/AlphaSlider/useAlphaSlider.ts | 23 +++++-- .../components/ColorArea/ColorArea.types.ts | 10 +++ .../library/src/components/ColorArea/index.ts | 11 ++- .../components/ColorArea/renderColorArea.tsx | 4 +- .../src/components/ColorArea/useColorArea.ts | 39 ++++++++--- .../ColorPicker/ColorPicker.types.ts | 6 ++ .../src/components/ColorPicker/index.ts | 5 +- .../ColorPicker/renderColorPicker.tsx | 4 +- .../components/ColorPicker/useColorPicker.ts | 24 +++++-- .../useColorPickerContextValues.ts | 17 ++++- .../ColorSlider/ColorSlider.types.ts | 4 ++ .../src/components/ColorSlider/index.ts | 12 +++- .../ColorSlider/renderColorSlider.tsx | 4 +- .../components/ColorSlider/useColorSlider.ts | 48 ++++++++++--- .../react-color-picker/library/src/index.ts | 40 +++++++++-- 24 files changed, 317 insertions(+), 54 deletions(-) create mode 100644 change/@fluentui-react-color-picker-414d04c8-72e8-4708-83a7-466c4d885578.json diff --git a/change/@fluentui-react-color-picker-414d04c8-72e8-4708-83a7-466c4d885578.json b/change/@fluentui-react-color-picker-414d04c8-72e8-4708-83a7-466c4d885578.json new file mode 100644 index 0000000000000..7f6acec5b5a9b --- /dev/null +++ b/change/@fluentui-react-color-picker-414d04c8-72e8-4708-83a7-466c4d885578.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat(react-color-picker): expose headless base APIs", + "packageName": "@fluentui/react-color-picker", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-color-picker/library/etc/react-color-picker.api.md b/packages/react-components/react-color-picker/library/etc/react-color-picker.api.md index ad703e55a1e67..8150bb0cb6b9a 100644 --- a/packages/react-components/react-color-picker/library/etc/react-color-picker.api.md +++ b/packages/react-components/react-color-picker/library/etc/react-color-picker.api.md @@ -6,6 +6,7 @@ import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; +import type { ContextSelector } from '@fluentui/react-context-selector'; import type { EventData } from '@fluentui/react-utilities'; import type { EventHandler } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; @@ -17,6 +18,12 @@ import type { SlotClassNames } from '@fluentui/react-utilities'; // @public export const AlphaSlider: ForwardRefComponent; +// @public +export type AlphaSliderBaseProps = Omit; + +// @public +export type AlphaSliderBaseState = Omit; + // @public (undocumented) export const alphaSliderClassNames: SlotClassNames; @@ -34,6 +41,12 @@ export type AlphaSliderState = ComponentState & Pick; +// @public +export type ColorAreaBaseProps = Omit; + +// @public +export type ColorAreaBaseState = ComponentState> & Pick; + // @public (undocumented) export const colorAreaClassNames: SlotClassNames; @@ -55,12 +68,33 @@ export type ColorAreaSlots = { // @public export type ColorAreaState = ComponentState> & Pick; +// @public (undocumented) +export type ColorChannel = 'hue' | 'saturation' | 'value'; + // @public export const ColorPicker: ForwardRefComponent; +// @public +export type ColorPickerBaseProps = Omit; + +// @public +export type ColorPickerBaseState = Omit; + // @public (undocumented) export const colorPickerClassNames: SlotClassNames; +// @public +export type ColorPickerContextValue = Pick & { + requestChange: (event: React_2.ChangeEvent, data: { + color: HsvColor; + }) => void; +}; + +// @public (undocumented) +export type ColorPickerContextValues = { + colorPicker: ColorPickerContextValue; +}; + // @public export type ColorPickerProps = Omit>, 'color'> & { color?: HsvColor; @@ -79,6 +113,12 @@ export type ColorPickerState = ComponentState & ColorPickerCon // @public export const ColorSlider: ForwardRefComponent; +// @public (undocumented) +export type ColorSliderBaseProps = Omit; + +// @public (undocumented) +export type ColorSliderBaseState = Omit; + // @public (undocumented) export const colorSliderClassNames: SlotClassNames; @@ -103,20 +143,23 @@ export type ColorSliderSlots = { export type ColorSliderState = ComponentState & Pick; // @public -export const renderAlphaSlider_unstable: (state: AlphaSliderState) => JSXElement; +export const renderAlphaSlider_unstable: (state: AlphaSliderBaseState) => JSXElement; // @public -export const renderColorArea_unstable: (state: ColorAreaState) => JSXElement; +export const renderColorArea_unstable: (state: ColorAreaBaseState) => JSXElement; // @public -export const renderColorPicker_unstable: (state: ColorPickerState, contextValues: ColorPickerContextValues) => JSXElement; +export const renderColorPicker_unstable: (state: ColorPickerBaseState, contextValues: ColorPickerContextValues) => JSXElement; // @public -export const renderColorSlider_unstable: (state: ColorSliderState) => JSXElement; +export const renderColorSlider_unstable: (state: ColorSliderBaseState) => JSXElement; // @public export const useAlphaSlider_unstable: (props: AlphaSliderProps, ref: React_2.Ref) => AlphaSliderState; +// @public (undocumented) +export const useAlphaSliderBase_unstable: (props: AlphaSliderBaseProps, ref: React_2.Ref) => AlphaSliderBaseState; + // @public export const useAlphaSliderStyles_unstable: (state: AlphaSliderState) => AlphaSliderState; @@ -124,17 +167,32 @@ export const useAlphaSliderStyles_unstable: (state: AlphaSliderState) => AlphaSl export const useColorArea_unstable: (props: ColorAreaProps, ref: React_2.Ref) => ColorAreaState; // @public -export const useColorAreaStyles_unstable: (state: ColorAreaState) => ColorAreaState; +export const useColorAreaBase_unstable: (props: ColorAreaBaseProps, ref: React_2.Ref) => ColorAreaBaseState; // @public +export const useColorAreaStyles_unstable: (state: ColorAreaState) => ColorAreaState; + +// @public (undocumented) export const useColorPicker_unstable: (props: ColorPickerProps, ref: React_2.Ref) => ColorPickerState; +// @public +export const useColorPickerBase_unstable: (props: ColorPickerBaseProps, ref: React_2.Ref) => ColorPickerBaseState; + +// @public (undocumented) +export const useColorPickerContextValue_unstable: (selector: ContextSelector) => T; + +// @public (undocumented) +export const useColorPickerContextValues: (state: ColorPickerState) => ColorPickerContextValues; + // @public export const useColorPickerStyles_unstable: (state: ColorPickerState) => ColorPickerState; // @public export const useColorSlider_unstable: (props: ColorSliderProps, ref: React_2.Ref) => ColorSliderState; +// @public +export const useColorSliderBase_unstable: (props: ColorSliderBaseProps, ref: React_2.Ref) => ColorSliderBaseState; + // @public export const useColorSliderStyles_unstable: (state: ColorSliderState) => ColorSliderState; diff --git a/packages/react-components/react-color-picker/library/src/AlphaSlider.ts b/packages/react-components/react-color-picker/library/src/AlphaSlider.ts index 42d1552ee9bb7..995e03f356b10 100644 --- a/packages/react-components/react-color-picker/library/src/AlphaSlider.ts +++ b/packages/react-components/react-color-picker/library/src/AlphaSlider.ts @@ -1,9 +1,16 @@ -export type { AlphaSliderProps, AlphaSliderSlots, AlphaSliderState } from './components/AlphaSlider/index'; +export type { + AlphaSliderBaseProps, + AlphaSliderBaseState, + AlphaSliderProps, + AlphaSliderSlots, + AlphaSliderState, +} from './components/AlphaSlider/index'; export { AlphaSlider, alphaSliderCSSVars, alphaSliderClassNames, renderAlphaSlider_unstable, + useAlphaSliderBase_unstable, useAlphaSliderStyles_unstable, useAlphaSlider_unstable, } from './components/AlphaSlider/index'; diff --git a/packages/react-components/react-color-picker/library/src/ColorArea.ts b/packages/react-components/react-color-picker/library/src/ColorArea.ts index ee9f532504896..27e2111d062f4 100644 --- a/packages/react-components/react-color-picker/library/src/ColorArea.ts +++ b/packages/react-components/react-color-picker/library/src/ColorArea.ts @@ -1,4 +1,6 @@ export type { + ColorAreaBaseProps, + ColorAreaBaseState, ColorAreaOnColorChangeData, ColorAreaProps, ColorAreaSlots, @@ -9,6 +11,7 @@ export { colorAreaCSSVars, colorAreaClassNames, renderColorArea_unstable, + useColorAreaBase_unstable, useColorAreaStyles_unstable, useColorArea_unstable, } from './components/ColorArea/index'; diff --git a/packages/react-components/react-color-picker/library/src/ColorPicker.ts b/packages/react-components/react-color-picker/library/src/ColorPicker.ts index 16e6329cf3ca2..b3c0abd0f45ef 100644 --- a/packages/react-components/react-color-picker/library/src/ColorPicker.ts +++ b/packages/react-components/react-color-picker/library/src/ColorPicker.ts @@ -1,4 +1,6 @@ export type { + ColorPickerBaseProps, + ColorPickerBaseState, ColorPickerOnChangeData, ColorPickerProps, ColorPickerSlots, @@ -8,6 +10,9 @@ export { ColorPicker, colorPickerClassNames, renderColorPicker_unstable, + useColorPickerBaseContextValues_unstable, + useColorPickerBase_unstable, + useColorPickerContextValues, useColorPickerStyles_unstable, useColorPicker_unstable, } from './components/ColorPicker/index'; diff --git a/packages/react-components/react-color-picker/library/src/ColorSlider.ts b/packages/react-components/react-color-picker/library/src/ColorSlider.ts index eee7bc5a3cb77..41fda287cd176 100644 --- a/packages/react-components/react-color-picker/library/src/ColorSlider.ts +++ b/packages/react-components/react-color-picker/library/src/ColorSlider.ts @@ -1,4 +1,7 @@ export type { + ColorChannel, + ColorSliderBaseProps, + ColorSliderBaseState, ColorSliderProps, ColorSliderSlots, ColorSliderState, @@ -9,6 +12,7 @@ export { colorSliderCSSVars, colorSliderClassNames, renderColorSlider_unstable, + useColorSliderBase_unstable, useColorSliderStyles_unstable, useColorSlider_unstable, } from './components/ColorSlider/index'; diff --git a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/AlphaSlider.types.ts b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/AlphaSlider.types.ts index 2d93d5cec854f..593387140a424 100644 --- a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/AlphaSlider.types.ts +++ b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/AlphaSlider.types.ts @@ -18,9 +18,19 @@ export type AlphaSliderProps = Omit & { transparency?: boolean; }; +/** + * AlphaSlider Base Props + */ +export type AlphaSliderBaseProps = Omit; + /** * State used in rendering AlphaSlider */ export type AlphaSliderState = ComponentState & Pick & Omit; + +/** + * State used in rendering unstyled AlphaSlider + */ +export type AlphaSliderBaseState = Omit; diff --git a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/index.ts b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/index.ts index 5e402c922c350..5cea7ec82c7e5 100644 --- a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/index.ts +++ b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/index.ts @@ -1,7 +1,13 @@ export { AlphaSlider } from './AlphaSlider'; -export type { AlphaSliderProps, AlphaSliderSlots, AlphaSliderState } from './AlphaSlider.types'; +export type { + AlphaSliderBaseProps, + AlphaSliderBaseState, + AlphaSliderProps, + AlphaSliderSlots, + AlphaSliderState, +} from './AlphaSlider.types'; export { renderAlphaSlider_unstable } from './renderAlphaSlider'; -export { useAlphaSlider_unstable } from './useAlphaSlider'; +export { useAlphaSliderBase_unstable, useAlphaSlider_unstable } from './useAlphaSlider'; export { alphaSliderCSSVars, alphaSliderClassNames, diff --git a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/renderAlphaSlider.tsx b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/renderAlphaSlider.tsx index 1654f849f5fd9..e9d2862a608be 100644 --- a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/renderAlphaSlider.tsx +++ b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/renderAlphaSlider.tsx @@ -3,12 +3,12 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { AlphaSliderState, AlphaSliderSlots } from './AlphaSlider.types'; +import type { AlphaSliderBaseState, AlphaSliderSlots } from './AlphaSlider.types'; /** * Render the final JSX of AlphaSlider */ -export const renderAlphaSlider_unstable = (state: AlphaSliderState): JSXElement => { +export const renderAlphaSlider_unstable = (state: AlphaSliderBaseState): JSXElement => { assertSlots(state); return ( diff --git a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.ts b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.ts index 468fc36d0687c..91c74365293d1 100644 --- a/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.ts +++ b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.ts @@ -2,7 +2,12 @@ import type * as React from 'react'; import { getPartitionedNativeProps, useId, slot } from '@fluentui/react-utilities'; -import type { AlphaSliderProps, AlphaSliderState } from './AlphaSlider.types'; +import type { + AlphaSliderBaseProps, + AlphaSliderBaseState, + AlphaSliderProps, + AlphaSliderState, +} from './AlphaSlider.types'; import { useAlphaSliderState_unstable } from './useAlphaSliderState'; import { useColorPickerContextValue_unstable } from '../../contexts/colorPicker'; @@ -20,6 +25,18 @@ export const useAlphaSlider_unstable = ( ref: React.Ref, ): AlphaSliderState => { const shapeFromContext = useColorPickerContextValue_unstable(ctx => ctx.shape); + const { shape = shapeFromContext, ...baseProps } = props; + + return { + ...useAlphaSliderBase_unstable(baseProps, ref), + shape, + }; +}; + +export const useAlphaSliderBase_unstable = ( + props: AlphaSliderBaseProps, + ref: React.Ref, +): AlphaSliderBaseState => { const nativeProps = getPartitionedNativeProps({ props, primarySlotTagName: 'input', @@ -27,7 +44,6 @@ export const useAlphaSlider_unstable = ( }); const { - shape = shapeFromContext, vertical, // Slots root, @@ -36,8 +52,7 @@ export const useAlphaSlider_unstable = ( thumb, } = props; - const state: AlphaSliderState = { - shape, + const state: AlphaSliderBaseState = { vertical, components: { input: 'input', diff --git a/packages/react-components/react-color-picker/library/src/components/ColorArea/ColorArea.types.ts b/packages/react-components/react-color-picker/library/src/components/ColorArea/ColorArea.types.ts index 922f1782bcec1..d3d7db1651602 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorArea/ColorArea.types.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorArea/ColorArea.types.ts @@ -35,7 +35,17 @@ export type ColorAreaProps = Omit>, 'colo onChange?: EventHandler; }; +/** + * ColorArea Base Props + */ +export type ColorAreaBaseProps = Omit; + /** * State used in rendering ColorArea */ export type ColorAreaState = ComponentState> & Pick; + +/** + * ColorArea Base State + */ +export type ColorAreaBaseState = ComponentState> & Pick; diff --git a/packages/react-components/react-color-picker/library/src/components/ColorArea/index.ts b/packages/react-components/react-color-picker/library/src/components/ColorArea/index.ts index eafdc6bbffe23..a08b9396e3353 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorArea/index.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorArea/index.ts @@ -1,5 +1,12 @@ export { ColorArea } from './ColorArea'; -export type { ColorAreaOnColorChangeData, ColorAreaProps, ColorAreaSlots, ColorAreaState } from './ColorArea.types'; +export type { + ColorAreaBaseProps, + ColorAreaBaseState, + ColorAreaOnColorChangeData, + ColorAreaProps, + ColorAreaSlots, + ColorAreaState, +} from './ColorArea.types'; export { renderColorArea_unstable } from './renderColorArea'; -export { useColorArea_unstable } from './useColorArea'; +export { useColorAreaBase_unstable, useColorArea_unstable } from './useColorArea'; export { colorAreaCSSVars, colorAreaClassNames, useColorAreaStyles_unstable } from './useColorAreaStyles.styles'; diff --git a/packages/react-components/react-color-picker/library/src/components/ColorArea/renderColorArea.tsx b/packages/react-components/react-color-picker/library/src/components/ColorArea/renderColorArea.tsx index f80addadcf3bf..7ab3aada88a75 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorArea/renderColorArea.tsx +++ b/packages/react-components/react-color-picker/library/src/components/ColorArea/renderColorArea.tsx @@ -3,12 +3,12 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { ColorAreaState, ColorAreaSlots } from './ColorArea.types'; +import type { ColorAreaBaseState, ColorAreaSlots } from './ColorArea.types'; /** * Render the final JSX of ColorArea */ -export const renderColorArea_unstable = (state: ColorAreaState): JSXElement => { +export const renderColorArea_unstable = (state: ColorAreaBaseState): JSXElement => { assertSlots(state); return ( diff --git a/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.ts b/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.ts index 66574f39be72c..7a4bda30490f8 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.ts @@ -3,7 +3,7 @@ import * as React from 'react'; import { tinycolor } from '@ctrl/tinycolor'; import { useId, slot, useMergedRefs, mergeCallbacks, getIntrinsicElementProps } from '@fluentui/react-utilities'; -import type { ColorAreaProps, ColorAreaState } from './ColorArea.types'; +import type { ColorAreaBaseProps, ColorAreaBaseState, ColorAreaProps, ColorAreaState } from './ColorArea.types'; import type { HsvColor } from '../../types/color'; import { colorAreaCSSVars } from './useColorAreaStyles.styles'; import { useEventCallback, useControllableState } from '@fluentui/react-utilities'; @@ -14,15 +14,17 @@ import { getCoordinates } from '../../utils/getCoordinates'; import { useColorPickerContextValue_unstable } from '../../contexts/colorPicker'; /** - * Create the state required to render ColorArea. + * Create the state required to render unstyled ColorArea. * - * The returned state can be modified with hooks such as useColorAreaStyles_unstable, - * before being passed to renderColorArea_unstable. + * The returned state can be modified with hooks before being passed to renderColorArea_unstable. * * @param props - props from this instance of ColorArea * @param ref - reference to root HTMLDivElement of ColorArea */ -export const useColorArea_unstable = (props: ColorAreaProps, ref: React.Ref): ColorAreaState => { +export const useColorAreaBase_unstable = ( + props: ColorAreaBaseProps, + ref: React.Ref, +): ColorAreaBaseState => { const { targetDocument } = useFluent(); const rootRef = React.useRef(null); const xRef = React.useRef(null); @@ -30,11 +32,9 @@ export const useColorArea_unstable = (props: ColorAreaProps, ref: React.Ref ctx.requestChange); const colorFromContext = useColorPickerContextValue_unstable(ctx => ctx.color); - const shapeFromContext = useColorPickerContextValue_unstable(ctx => ctx.shape); const { onChange = onChangeFromContext as unknown as ColorAreaProps['onChange'], - shape = shapeFromContext, // Slots inputX, inputY, @@ -164,8 +164,7 @@ export const useColorArea_unstable = (props: ColorAreaProps, ref: React.Ref): ColorAreaState => { + const shapeFromContext = useColorPickerContextValue_unstable(ctx => ctx.shape); + + const { shape = shapeFromContext, ...rest } = props; + + const state: ColorAreaState = { + ...useColorAreaBase_unstable(rest, ref), + shape, + }; + + return state; +}; diff --git a/packages/react-components/react-color-picker/library/src/components/ColorPicker/ColorPicker.types.ts b/packages/react-components/react-color-picker/library/src/components/ColorPicker/ColorPicker.types.ts index 88db0e86242cc..6c720014b6bfe 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorPicker/ColorPicker.types.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorPicker/ColorPicker.types.ts @@ -32,7 +32,13 @@ export type ColorPickerProps = Omit>, ' shape?: 'rounded' | 'square'; }; +/** ColorPicker Base Props */ +export type ColorPickerBaseProps = Omit; + /** * State used in rendering ColorPicker */ export type ColorPickerState = ComponentState & ColorPickerContextValue; + +/** State used in rendering unstyled ColorPicker */ +export type ColorPickerBaseState = Omit; diff --git a/packages/react-components/react-color-picker/library/src/components/ColorPicker/index.ts b/packages/react-components/react-color-picker/library/src/components/ColorPicker/index.ts index f85f52b20eca3..1a2740e7fc72e 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorPicker/index.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorPicker/index.ts @@ -1,10 +1,13 @@ export { ColorPicker } from './ColorPicker'; export type { + ColorPickerBaseProps, + ColorPickerBaseState, ColorPickerOnChangeData, ColorPickerProps, ColorPickerSlots, ColorPickerState, } from './ColorPicker.types'; export { renderColorPicker_unstable } from './renderColorPicker'; -export { useColorPicker_unstable } from './useColorPicker'; +export { useColorPickerBase_unstable, useColorPicker_unstable } from './useColorPicker'; +export { useColorPickerBaseContextValues_unstable, useColorPickerContextValues } from './useColorPickerContextValues'; export { colorPickerClassNames, useColorPickerStyles_unstable } from './useColorPickerStyles.styles'; diff --git a/packages/react-components/react-color-picker/library/src/components/ColorPicker/renderColorPicker.tsx b/packages/react-components/react-color-picker/library/src/components/ColorPicker/renderColorPicker.tsx index 882b6a4fabb3d..53aa154dbb1ba 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorPicker/renderColorPicker.tsx +++ b/packages/react-components/react-color-picker/library/src/components/ColorPicker/renderColorPicker.tsx @@ -3,7 +3,7 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { ColorPickerState, ColorPickerSlots } from './ColorPicker.types'; +import type { ColorPickerBaseState, ColorPickerSlots } from './ColorPicker.types'; import type { ColorPickerContextValues } from '../../contexts/colorPicker'; import { ColorPickerProvider } from '../../contexts/colorPicker'; @@ -11,7 +11,7 @@ import { ColorPickerProvider } from '../../contexts/colorPicker'; * Render the final JSX of ColorPicker */ export const renderColorPicker_unstable = ( - state: ColorPickerState, + state: ColorPickerBaseState, contextValues: ColorPickerContextValues, ): JSXElement => { assertSlots(state); diff --git a/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.ts b/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.ts index ed7816a8f308b..d0b0188f0c597 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.ts @@ -2,7 +2,12 @@ import type * as React from 'react'; import { getIntrinsicElementProps, slot, useEventCallback } from '@fluentui/react-utilities'; -import type { ColorPickerProps, ColorPickerState } from './ColorPicker.types'; +import type { + ColorPickerBaseProps, + ColorPickerBaseState, + ColorPickerProps, + ColorPickerState, +} from './ColorPicker.types'; /** * Create the state required to render ColorPicker. * @@ -12,10 +17,13 @@ import type { ColorPickerProps, ColorPickerState } from './ColorPicker.types'; * @param props - props from this instance of ColorPicker * @param ref - reference to root HTMLDivElement of ColorPicker */ -export const useColorPicker_unstable = (props: ColorPickerProps, ref: React.Ref): ColorPickerState => { - const { color, onColorChange, shape, ...rest } = props; +export const useColorPickerBase_unstable = ( + props: ColorPickerBaseProps, + ref: React.Ref, +): ColorPickerBaseState => { + const { color, onColorChange, ...rest } = props; - const requestChange: ColorPickerState['requestChange'] = useEventCallback((event, data) => { + const requestChange: ColorPickerBaseState['requestChange'] = useEventCallback((event, data) => { onColorChange?.(event, { type: 'change', event, @@ -36,6 +44,14 @@ export const useColorPicker_unstable = (props: ColorPickerProps, ref: React.Ref< ), color, requestChange, + }; +}; + +export const useColorPicker_unstable = (props: ColorPickerProps, ref: React.Ref): ColorPickerState => { + const { shape, ...baseProps } = props; + + return { + ...useColorPickerBase_unstable(baseProps, ref), shape, }; }; diff --git a/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPickerContextValues.ts b/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPickerContextValues.ts index 4bcc2c961e741..749ac73fb35e7 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPickerContextValues.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPickerContextValues.ts @@ -2,7 +2,22 @@ import * as React from 'react'; import type { ColorPickerContextValue, ColorPickerContextValues } from '../../contexts/colorPicker'; -import type { ColorPickerState } from './ColorPicker.types'; +import type { ColorPickerBaseState, ColorPickerState } from './ColorPicker.types'; + +export const useColorPickerBaseContextValues_unstable = (state: ColorPickerBaseState): ColorPickerContextValues => { + const { color, requestChange } = state; + + const colorPicker = React.useMemo( + () => ({ + requestChange, + color, + shape: undefined, + }), + [requestChange, color], + ); + + return { colorPicker }; +}; export const useColorPickerContextValues = (state: ColorPickerState): ColorPickerContextValues => { const { color, shape, requestChange } = state; diff --git a/packages/react-components/react-color-picker/library/src/components/ColorSlider/ColorSlider.types.ts b/packages/react-components/react-color-picker/library/src/components/ColorSlider/ColorSlider.types.ts index 5c40e90d4f719..337e1fe684a39 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorSlider/ColorSlider.types.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorSlider/ColorSlider.types.ts @@ -52,8 +52,12 @@ export type ColorSliderProps = Omit< defaultColor?: HsvColor; }; +export type ColorSliderBaseProps = Omit; + /** * State used in rendering ColorSlider */ export type ColorSliderState = ComponentState & Pick; + +export type ColorSliderBaseState = Omit; diff --git a/packages/react-components/react-color-picker/library/src/components/ColorSlider/index.ts b/packages/react-components/react-color-picker/library/src/components/ColorSlider/index.ts index 822748e81e342..806cf8667bb31 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorSlider/index.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorSlider/index.ts @@ -1,7 +1,15 @@ export { ColorSlider } from './ColorSlider'; -export type { ColorSliderProps, ColorSliderSlots, ColorSliderState, SliderOnChangeData } from './ColorSlider.types'; +export type { + ColorChannel, + ColorSliderBaseProps, + ColorSliderBaseState, + ColorSliderProps, + ColorSliderSlots, + ColorSliderState, + SliderOnChangeData, +} from './ColorSlider.types'; export { renderColorSlider_unstable } from './renderColorSlider'; -export { useColorSlider_unstable } from './useColorSlider'; +export { useColorSliderBase_unstable, useColorSlider_unstable } from './useColorSlider'; export { colorSliderCSSVars, colorSliderClassNames, diff --git a/packages/react-components/react-color-picker/library/src/components/ColorSlider/renderColorSlider.tsx b/packages/react-components/react-color-picker/library/src/components/ColorSlider/renderColorSlider.tsx index 2d1ccfe16a618..44e40281a3935 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorSlider/renderColorSlider.tsx +++ b/packages/react-components/react-color-picker/library/src/components/ColorSlider/renderColorSlider.tsx @@ -3,12 +3,12 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { ColorSliderState, ColorSliderSlots } from './ColorSlider.types'; +import type { ColorSliderBaseState, ColorSliderSlots } from './ColorSlider.types'; /** * Render the final JSX of ColorSlider */ -export const renderColorSlider_unstable = (state: ColorSliderState): JSXElement => { +export const renderColorSlider_unstable = (state: ColorSliderBaseState): JSXElement => { assertSlots(state); return ( diff --git a/packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.ts b/packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.ts index 9d5344a85b3f0..612c271141692 100644 --- a/packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.ts +++ b/packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.ts @@ -11,7 +11,12 @@ import { } from '@fluentui/react-utilities'; import { colorSliderCSSVars } from './useColorSliderStyles.styles'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; -import type { ColorSliderProps, ColorSliderState } from './ColorSlider.types'; +import type { + ColorSliderBaseProps, + ColorSliderBaseState, + ColorSliderProps, + ColorSliderState, +} from './ColorSlider.types'; import { useColorPickerContextValue_unstable } from '../../contexts/colorPicker'; import { MIN, HUE_MAX, MAX as COLOR_MAX } from '../../utils/constants'; import { getPercent } from '../../utils/getPercent'; @@ -21,24 +26,22 @@ import type { HsvColor } from '../../types/color'; import { INITIAL_COLOR_HSV } from '../../utils/constants'; /** - * Create the state required to render ColorSlider. + * Create the base state required to render unstyled ColorSlider. * - * The returned state can be modified with hooks such as useColorSliderStyles_unstable, - * before being passed to renderColorSlider_unstable. + * The returned state can be modified with hooks before being passed to renderColorSlider_unstable. * * @param props - props from this instance of ColorSlider * @param ref - reference to root HTMLInputElement of ColorSlider */ -export const useColorSlider_unstable = ( - props: ColorSliderProps, +export const useColorSliderBase_unstable = ( + props: ColorSliderBaseProps, ref: React.Ref, -): ColorSliderState => { +): ColorSliderBaseState => { 'use no memo'; // justified: compiler would optimize useColorSlider_unstable — manual opt-out to preserve runtime behavior const { dir } = useFluent(); const onChangeFromContext = useColorPickerContextValue_unstable(ctx => ctx.requestChange); const colorFromContext = useColorPickerContextValue_unstable(ctx => ctx.color); - const shapeFromContext = useColorPickerContextValue_unstable(ctx => ctx.shape); const nativeProps = getPartitionedNativeProps({ props, primarySlotTagName: 'input', @@ -49,7 +52,6 @@ export const useColorSlider_unstable = ( color, channel = 'hue', onChange = onChangeFromContext, - shape = shapeFromContext, vertical, // Slots root, @@ -110,8 +112,7 @@ export const useColorSlider_unstable = ( : `hsl(${hslColor.h} 100%, 50%)`, }; - const state: ColorSliderState = { - shape, + const state: ColorSliderBaseState = { vertical, channel, components: { @@ -155,3 +156,28 @@ export const useColorSlider_unstable = ( state.input.onChange = _onChange; return state; }; + +/** + * Create the state required to render ColorSlider. + * + * The returned state can be modified with hooks such as useColorSliderStyles_unstable, + * before being passed to renderColorSlider_unstable. + * + * @param props - props from this instance of ColorSlider + * @param ref - reference to root HTMLInputElement of ColorSlider + */ +export const useColorSlider_unstable = ( + props: ColorSliderProps, + ref: React.Ref, +): ColorSliderState => { + const shapeFromContext = useColorPickerContextValue_unstable(ctx => ctx.shape); + + const { shape = shapeFromContext, ...baseProps } = props; + + const state: ColorSliderState = { + ...useColorSliderBase_unstable(baseProps, ref), + shape, + }; + + return state; +}; diff --git a/packages/react-components/react-color-picker/library/src/index.ts b/packages/react-components/react-color-picker/library/src/index.ts index 05ff72f1c4117..0afbf9297b027 100644 --- a/packages/react-components/react-color-picker/library/src/index.ts +++ b/packages/react-components/react-color-picker/library/src/index.ts @@ -1,32 +1,64 @@ -export type { ColorSliderProps, ColorSliderSlots, ColorSliderState } from './ColorSlider'; +export type { + ColorChannel, + ColorSliderBaseProps, + ColorSliderBaseState, + ColorSliderProps, + ColorSliderSlots, + ColorSliderState, +} from './ColorSlider'; export { ColorSlider, colorSliderClassNames, renderColorSlider_unstable, + useColorSliderBase_unstable, useColorSliderStyles_unstable, useColorSlider_unstable, } from './ColorSlider'; -export type { ColorPickerProps, ColorPickerSlots, ColorPickerState } from './ColorPicker'; +export type { + ColorPickerBaseProps, + ColorPickerBaseState, + ColorPickerProps, + ColorPickerSlots, + ColorPickerState, +} from './ColorPicker'; export { ColorPicker, colorPickerClassNames, renderColorPicker_unstable, + useColorPickerBase_unstable, + useColorPickerContextValues, useColorPickerStyles_unstable, useColorPicker_unstable, } from './ColorPicker'; -export type { ColorAreaProps, ColorAreaSlots, ColorAreaState } from './ColorArea'; +export type { + ColorAreaBaseProps, + ColorAreaBaseState, + ColorAreaProps, + ColorAreaSlots, + ColorAreaState, +} from './ColorArea'; export { ColorArea, colorAreaClassNames, renderColorArea_unstable, + useColorAreaBase_unstable, useColorAreaStyles_unstable, useColorArea_unstable, } from './ColorArea'; -export type { AlphaSliderProps, AlphaSliderSlots, AlphaSliderState } from './AlphaSlider'; +export type { + AlphaSliderBaseProps, + AlphaSliderBaseState, + AlphaSliderProps, + AlphaSliderSlots, + AlphaSliderState, +} from './AlphaSlider'; export { AlphaSlider, alphaSliderClassNames, renderAlphaSlider_unstable, + useAlphaSliderBase_unstable, useAlphaSliderStyles_unstable, useAlphaSlider_unstable, } from './AlphaSlider'; +export type { ColorPickerContextValue, ColorPickerContextValues } from './contexts/colorPicker'; +export { useColorPickerContextValue_unstable } from './contexts/colorPicker'; From 141feed5e8c03581e4d49c65f476b7858786854f Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 7 Aug 2026 22:53:09 +0200 Subject: [PATCH 5/5] feat(react-headless-components-preview): add color picker controls (#36518) Co-authored-by: Victor Genaev --- ...-0221c32b-cafa-4870-940b-ba5ac0064fe3.json | 7 + .../bundle-size/AllComponents.fixture.js | 2 + .../library/etc/color-picker.api.md | 107 ++++++++ .../library/package.json | 7 + .../library/src/color-picker.ts | 32 +++ .../AlphaSlider/AlphaSlider.test.tsx | 47 ++++ .../ColorPicker/AlphaSlider/AlphaSlider.tsx | 18 ++ .../AlphaSlider/AlphaSlider.types.ts | 22 ++ .../ColorPicker/AlphaSlider/index.ts | 4 + .../AlphaSlider/renderAlphaSlider.ts | 1 + .../ColorPicker/AlphaSlider/useAlphaSlider.ts | 19 ++ .../ColorPicker/ColorArea/ColorArea.test.tsx | 25 ++ .../ColorPicker/ColorArea/ColorArea.tsx | 18 ++ .../ColorPicker/ColorArea/ColorArea.types.ts | 5 + .../components/ColorPicker/ColorArea/index.ts | 4 + .../ColorPicker/ColorArea/renderColorArea.ts | 1 + .../ColorPicker/ColorArea/useColorArea.ts | 1 + .../ColorPicker/ColorPicker.test.tsx | 37 +++ .../components/ColorPicker/ColorPicker.tsx | 20 ++ .../ColorPicker/ColorPicker.types.ts | 6 + .../ColorSlider/ColorSlider.test.tsx | 23 ++ .../ColorPicker/ColorSlider/ColorSlider.tsx | 18 ++ .../ColorSlider/ColorSlider.types.ts | 17 ++ .../ColorPicker/ColorSlider/index.ts | 4 + .../ColorSlider/renderColorSlider.ts | 1 + .../ColorPicker/ColorSlider/useColorSlider.ts | 16 ++ .../src/components/ColorPicker/index.ts | 19 ++ .../ColorPicker/renderColorPicker.ts | 1 + .../components/ColorPicker/useColorPicker.ts | 1 + .../useColorPickerContextValues.ts | 4 + .../AlphaSliderDefault.stories.tsx | 52 ++++ .../ColorPicker/ColorAreaDefault.stories.tsx | 51 ++++ .../ColorPickerDefault.stories.tsx | 74 ++++++ .../src/ColorPicker/ColorPickerDescription.md | 3 + .../ColorSliderDefault.stories.tsx | 61 +++++ .../src/ColorPicker/color-picker.module.css | 235 ++++++++++++++++++ .../stories/src/ColorPicker/index.stories.tsx | 31 +++ yarn.lock | 1 + 38 files changed, 995 insertions(+) create mode 100644 change/@fluentui-react-headless-components-preview-0221c32b-cafa-4870-940b-ba5ac0064fe3.json create mode 100644 packages/react-components/react-headless-components-preview/library/etc/color-picker.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/color-picker.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/renderAlphaSlider.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/useAlphaSlider.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/renderColorArea.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/useColorArea.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/renderColorSlider.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/useColorSlider.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/renderColorPicker.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/useColorPicker.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/useColorPickerContextValues.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ColorPicker/AlphaSliderDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorAreaDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorPickerDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorPickerDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorSliderDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ColorPicker/color-picker.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/ColorPicker/index.stories.tsx diff --git a/change/@fluentui-react-headless-components-preview-0221c32b-cafa-4870-940b-ba5ac0064fe3.json b/change/@fluentui-react-headless-components-preview-0221c32b-cafa-4870-940b-ba5ac0064fe3.json new file mode 100644 index 0000000000000..914e55331c8de --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-0221c32b-cafa-4870-940b-ba5ac0064fe3.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add color picker controls", + "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 3485a0285aa9e..d2b7af34c33e3 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 @@ -6,6 +6,7 @@ import * as Breadcrumb from '@fluentui/react-headless-components-preview/breadcr import * as Button from '@fluentui/react-headless-components-preview/button'; import * as Card from '@fluentui/react-headless-components-preview/card'; import * as Checkbox from '@fluentui/react-headless-components-preview/checkbox'; +import * as ColorPicker from '@fluentui/react-headless-components-preview/color-picker'; import * as Combobox from '@fluentui/react-headless-components-preview/combobox'; import * as CompoundButton from '@fluentui/react-headless-components-preview/compound-button'; import * as Dialog from '@fluentui/react-headless-components-preview/dialog'; @@ -57,6 +58,7 @@ console.log({ Button, Card, Checkbox, + ColorPicker, Combobox, CompoundButton, Dialog, diff --git a/packages/react-components/react-headless-components-preview/library/etc/color-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/color-picker.api.md new file mode 100644 index 0000000000000..d29a3dd5b7002 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/color-picker.api.md @@ -0,0 +1,107 @@ +## 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 { AlphaSliderBaseState } from '@fluentui/react-color-picker'; +import { AlphaSliderBaseProps as AlphaSliderProps } from '@fluentui/react-color-picker'; +import { AlphaSliderSlots } from '@fluentui/react-color-picker'; +import { ColorAreaBaseProps as ColorAreaProps } from '@fluentui/react-color-picker'; +import { ColorAreaSlots } from '@fluentui/react-color-picker'; +import { ColorAreaBaseState as ColorAreaState } from '@fluentui/react-color-picker'; +import type { ColorChannel } from '@fluentui/react-color-picker'; +import { ColorPickerContextValue } from '@fluentui/react-color-picker'; +import { ColorPickerBaseProps as ColorPickerProps } from '@fluentui/react-color-picker'; +import { ColorPickerSlots } from '@fluentui/react-color-picker'; +import { ColorPickerBaseState as ColorPickerState } from '@fluentui/react-color-picker'; +import type { ColorSliderBaseState } from '@fluentui/react-color-picker'; +import { ColorSliderBaseProps as ColorSliderProps } from '@fluentui/react-color-picker'; +import { ColorSliderSlots } from '@fluentui/react-color-picker'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type * as React_2 from 'react'; +import { renderAlphaSlider_unstable as renderAlphaSlider } from '@fluentui/react-color-picker'; +import { renderColorArea_unstable as renderColorArea } from '@fluentui/react-color-picker'; +import { renderColorPicker_unstable as renderColorPicker } from '@fluentui/react-color-picker'; +import { renderColorSlider_unstable as renderColorSlider } from '@fluentui/react-color-picker'; +import { useColorAreaBase_unstable as useColorArea } from '@fluentui/react-color-picker'; +import { useColorPickerBase_unstable as useColorPicker } from '@fluentui/react-color-picker'; +import { useColorPickerContextValue_unstable as useColorPickerContextValue } from '@fluentui/react-color-picker'; +import { useColorPickerContextValues } from '@fluentui/react-color-picker'; + +// @public +export const AlphaSlider: ForwardRefComponent; + +export { AlphaSliderProps } + +export { AlphaSliderSlots } + +// @public (undocumented) +export type AlphaSliderState = AlphaSliderBaseState & { + root: { + 'data-channel'?: AlphaSliderBaseState['channel']; + 'data-orientation'?: 'horizontal' | 'vertical'; + 'data-transparency'?: string; + }; +}; + +// @public +export const ColorArea: ForwardRefComponent; + +export { ColorAreaProps } + +export { ColorAreaSlots } + +export { ColorAreaState } + +// @public +export const ColorPicker: ForwardRefComponent; + +export { ColorPickerContextValue } + +export { ColorPickerProps } + +export { ColorPickerSlots } + +export { ColorPickerState } + +// @public +export const ColorSlider: ForwardRefComponent; + +export { ColorSliderProps } + +export { ColorSliderSlots } + +// @public (undocumented) +export type ColorSliderState = ColorSliderBaseState & { + root: { + 'data-channel'?: ColorChannel; + 'data-orientation'?: 'horizontal' | 'vertical'; + }; +}; + +export { renderAlphaSlider } + +export { renderColorArea } + +export { renderColorPicker } + +export { renderColorSlider } + +// @public (undocumented) +export const useAlphaSlider: (props: AlphaSliderProps, ref: React_2.Ref) => AlphaSliderState; + +export { useColorArea } + +export { useColorPicker } + +export { useColorPickerContextValue } + +export { useColorPickerContextValues } + +// @public (undocumented) +export const useColorSlider: (props: ColorSliderProps, ref: React_2.Ref) => ColorSliderState; + +// (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 718fb2bcb0919..2a32c5be1b217 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -27,6 +27,7 @@ "@fluentui/react-button": "^9.10.1", "@fluentui/react-card": "^9.7.1", "@fluentui/react-checkbox": "^9.6.3", + "@fluentui/react-color-picker": "^9.2.19", "@fluentui/react-combobox": "^9.17.4", "@fluentui/react-context-selector": "^9.2.18", "@fluentui/react-dialog": "^9.18.2", @@ -124,6 +125,12 @@ "import": "./lib/checkbox.js", "require": "./lib-commonjs/checkbox.js" }, + "./color-picker": { + "types": "./dist/color-picker.d.ts", + "node": "./lib-commonjs/color-picker.js", + "import": "./lib/color-picker.js", + "require": "./lib-commonjs/color-picker.js" + }, "./combobox": { "types": "./dist/combobox.d.ts", "node": "./lib-commonjs/combobox.js", diff --git a/packages/react-components/react-headless-components-preview/library/src/color-picker.ts b/packages/react-components/react-headless-components-preview/library/src/color-picker.ts new file mode 100644 index 0000000000000..8c48ba3e7e318 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/color-picker.ts @@ -0,0 +1,32 @@ +export type { + AlphaSliderProps, + AlphaSliderSlots, + AlphaSliderState, + ColorAreaProps, + ColorAreaSlots, + ColorAreaState, + ColorPickerContextValue, + ColorPickerProps, + ColorPickerSlots, + ColorPickerState, + ColorSliderProps, + ColorSliderSlots, + ColorSliderState, +} from './components/ColorPicker/index'; + +export { + AlphaSlider, + renderAlphaSlider, + useAlphaSlider, + ColorArea, + renderColorArea, + useColorArea, + ColorSlider, + renderColorSlider, + useColorSlider, + ColorPicker, + renderColorPicker, + useColorPicker, + useColorPickerContextValue, + useColorPickerContextValues, +} from './components/ColorPicker/index'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.test.tsx new file mode 100644 index 0000000000000..1ac4824af83b4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { AlphaSlider } from './AlphaSlider'; + +describe('AlphaSlider', () => { + isConformant({ + Component: AlphaSlider, + displayName: 'AlphaSlider', + disabledTests: ['has-top-level-file-extra'], + primarySlot: 'input', + }); + + it('renders opacity as a native range value', () => { + const { container } = render(); + + expect(screen.getByRole('slider', { name: 'Opacity' })).toHaveValue('70'); + expect(container.firstElementChild).toHaveAttribute('data-orientation', 'horizontal'); + expect(container.firstElementChild).not.toHaveAttribute('data-transparency'); + }); + + it('exposes transparency state, protects reserved attributes, and reports changes', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + expect(container.firstElementChild).toHaveAttribute('data-orientation', 'vertical'); + expect(container.firstElementChild).toHaveAttribute('data-transparency'); + + fireEvent.change(screen.getByRole('slider', { name: 'Transparency' }), { target: { value: '20' } }); + expect(onChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + color: { h: 20, s: 0.4, v: 0.8, a: 0.8 }, + }), + ); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.tsx b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.tsx new file mode 100644 index 0000000000000..9f65f2e5d5cc7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { AlphaSliderProps } from './AlphaSlider.types'; +import { useAlphaSlider } from './useAlphaSlider'; +import { renderAlphaSlider } from './renderAlphaSlider'; + +/** + * A headless slider for selecting color opacity or transparency. + */ +export const AlphaSlider: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useAlphaSlider(props, ref); + + return renderAlphaSlider(state); +}); + +AlphaSlider.displayName = 'AlphaSlider'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.types.ts new file mode 100644 index 0000000000000..739c610cf3fab --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/AlphaSlider.types.ts @@ -0,0 +1,22 @@ +import type { AlphaSliderBaseState } from '@fluentui/react-color-picker'; + +export type { AlphaSliderBaseProps as AlphaSliderProps, AlphaSliderSlots } from '@fluentui/react-color-picker'; + +export type AlphaSliderState = AlphaSliderBaseState & { + root: { + /** + * The color channel that the slider represents. + */ + 'data-channel'?: AlphaSliderBaseState['channel']; + + /** + * The slider orientation. + */ + 'data-orientation'?: 'horizontal' | 'vertical'; + + /** + * Present when the slider value represents transparency instead of opacity. + */ + 'data-transparency'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/index.ts new file mode 100644 index 0000000000000..4836104f24c3d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/index.ts @@ -0,0 +1,4 @@ +export { AlphaSlider } from './AlphaSlider'; +export { renderAlphaSlider } from './renderAlphaSlider'; +export { useAlphaSlider } from './useAlphaSlider'; +export type { AlphaSliderProps, AlphaSliderSlots, AlphaSliderState } from './AlphaSlider.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/renderAlphaSlider.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/renderAlphaSlider.ts new file mode 100644 index 0000000000000..1ad598faef67e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/renderAlphaSlider.ts @@ -0,0 +1 @@ +export { renderAlphaSlider_unstable as renderAlphaSlider } from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/useAlphaSlider.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/useAlphaSlider.ts new file mode 100644 index 0000000000000..3ec6cb37a281d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/AlphaSlider/useAlphaSlider.ts @@ -0,0 +1,19 @@ +'use client'; + +import type * as React from 'react'; +import { useAlphaSliderBase_unstable } from '@fluentui/react-color-picker'; +import type { AlphaSliderProps, AlphaSliderState } from './AlphaSlider.types'; +import { stringifyDataAttribute } from '../../../utils'; + +export const useAlphaSlider = (props: AlphaSliderProps, ref: React.Ref): AlphaSliderState => { + const state: AlphaSliderState = useAlphaSliderBase_unstable(props, ref); + + // eslint-disable-next-line react-hooks/immutability + state.root['data-channel'] = state.channel; + // eslint-disable-next-line react-hooks/immutability + state.root['data-orientation'] = state.vertical ? 'vertical' : 'horizontal'; + // eslint-disable-next-line react-hooks/immutability + state.root['data-transparency'] = stringifyDataAttribute(props.transparency); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.test.tsx new file mode 100644 index 0000000000000..9f825faa0c625 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.test.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { ColorArea } from './ColorArea'; + +describe('ColorArea', () => { + isConformant({ + Component: ColorArea, + displayName: 'ColorArea', + disabledTests: ['has-top-level-file-extra'], + }); + + it('renders two native range inputs with the current color', () => { + render( + , + ); + + expect(screen.getByRole('slider', { name: 'Saturation' })).toHaveValue('40'); + expect(screen.getByRole('slider', { name: 'Value' })).toHaveValue('80'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.tsx b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.tsx new file mode 100644 index 0000000000000..cc8987c9cbd11 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ColorAreaProps } from './ColorArea.types'; +import { useColorArea } from './useColorArea'; +import { renderColorArea } from './renderColorArea'; + +/** + * A two-dimensional headless control for selecting color saturation and value. + */ +export const ColorArea: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useColorArea(props, ref); + + return renderColorArea(state); +}); + +ColorArea.displayName = 'ColorArea'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.types.ts new file mode 100644 index 0000000000000..371c5ebec58cb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/ColorArea.types.ts @@ -0,0 +1,5 @@ +export type { + ColorAreaBaseProps as ColorAreaProps, + ColorAreaBaseState as ColorAreaState, + ColorAreaSlots, +} from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/index.ts new file mode 100644 index 0000000000000..625654a5c1a48 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/index.ts @@ -0,0 +1,4 @@ +export { ColorArea } from './ColorArea'; +export { renderColorArea } from './renderColorArea'; +export { useColorArea } from './useColorArea'; +export type { ColorAreaProps, ColorAreaSlots, ColorAreaState } from './ColorArea.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/renderColorArea.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/renderColorArea.ts new file mode 100644 index 0000000000000..daf5559e6121d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/renderColorArea.ts @@ -0,0 +1 @@ +export { renderColorArea_unstable as renderColorArea } from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/useColorArea.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/useColorArea.ts new file mode 100644 index 0000000000000..5a98544c00d2d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorArea/useColorArea.ts @@ -0,0 +1 @@ +export { useColorAreaBase_unstable as useColorArea } from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.test.tsx new file mode 100644 index 0000000000000..cfe3d268026c7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.test.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ColorSlider } from './ColorSlider/ColorSlider'; +import { isConformant } from '../../testing/isConformant'; +import { ColorPicker } from './ColorPicker'; + +describe('ColorPicker', () => { + isConformant({ + Component: ColorPicker, + displayName: 'ColorPicker', + }); + + it('renders a semantic root and forwards native props', () => { + render(); + + expect(screen.getByTestId('picker')).toHaveClass('custom'); + }); + + it('coordinates child controls through context', () => { + const onColorChange = jest.fn(); + + render( + + + , + ); + + fireEvent.change(screen.getByRole('slider', { name: 'Hue' }), { target: { value: '120' } }); + + expect(onColorChange).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + color: { h: 120, s: 0.4, v: 0.8, a: 1 }, + }), + ); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.tsx b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.tsx new file mode 100644 index 0000000000000..939d464328637 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ColorPickerProps } from './ColorPicker.types'; +import { useColorPicker } from './useColorPicker'; +import { useColorPickerContextValues } from './useColorPickerContextValues'; +import { renderColorPicker } from './renderColorPicker'; + +/** + * Coordinates headless color controls and reports their color changes. + */ +export const ColorPicker: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useColorPicker(props, ref); + const contextValues = useColorPickerContextValues(state); + + return renderColorPicker(state, contextValues); +}); + +ColorPicker.displayName = 'ColorPicker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.types.ts new file mode 100644 index 0000000000000..6ddaf23e2ae98 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorPicker.types.ts @@ -0,0 +1,6 @@ +export type { + ColorPickerBaseProps as ColorPickerProps, + ColorPickerBaseState as ColorPickerState, + ColorPickerContextValue, + ColorPickerSlots, +} from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.test.tsx new file mode 100644 index 0000000000000..f244d7fc0284b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.test.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { isConformant } from '../../../testing/isConformant'; +import { ColorSlider } from './ColorSlider'; + +describe('ColorSlider', () => { + isConformant({ + Component: ColorSlider, + displayName: 'ColorSlider', + disabledTests: ['has-top-level-file-extra'], + primarySlot: 'input', + }); + + it('renders a native range input with channel semantics', () => { + const { container } = render( + , + ); + + expect(screen.getByRole('slider', { name: 'Saturation' })).toHaveValue('40'); + expect(container.firstElementChild).toHaveAttribute('data-channel', 'saturation'); + expect(container.firstElementChild).toHaveAttribute('data-orientation', 'horizontal'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.tsx b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.tsx new file mode 100644 index 0000000000000..5c018142988b3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ColorSliderProps } from './ColorSlider.types'; +import { useColorSlider } from './useColorSlider'; +import { renderColorSlider } from './renderColorSlider'; + +/** + * A headless slider for selecting a hue, saturation, or value color channel. + */ +export const ColorSlider: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useColorSlider(props, ref); + + return renderColorSlider(state); +}); + +ColorSlider.displayName = 'ColorSlider'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.types.ts new file mode 100644 index 0000000000000..1411051b0ad5b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/ColorSlider.types.ts @@ -0,0 +1,17 @@ +import type { ColorChannel, ColorSliderBaseState } from '@fluentui/react-color-picker'; + +export type { ColorSliderBaseProps as ColorSliderProps, ColorSliderSlots } from '@fluentui/react-color-picker'; + +export type ColorSliderState = ColorSliderBaseState & { + root: { + /** + * The color channel controlled by the slider. + */ + 'data-channel'?: ColorChannel; + + /** + * The slider orientation. + */ + 'data-orientation'?: 'horizontal' | 'vertical'; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/index.ts new file mode 100644 index 0000000000000..c3722c93270a5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/index.ts @@ -0,0 +1,4 @@ +export { ColorSlider } from './ColorSlider'; +export { renderColorSlider } from './renderColorSlider'; +export { useColorSlider } from './useColorSlider'; +export type { ColorSliderProps, ColorSliderSlots, ColorSliderState } from './ColorSlider.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/renderColorSlider.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/renderColorSlider.ts new file mode 100644 index 0000000000000..a871471009f19 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/renderColorSlider.ts @@ -0,0 +1 @@ +export { renderColorSlider_unstable as renderColorSlider } from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/useColorSlider.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/useColorSlider.ts new file mode 100644 index 0000000000000..ee409a0317389 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/ColorSlider/useColorSlider.ts @@ -0,0 +1,16 @@ +'use client'; + +import type * as React from 'react'; +import { useColorSliderBase_unstable } from '@fluentui/react-color-picker'; +import type { ColorSliderProps, ColorSliderState } from './ColorSlider.types'; + +export const useColorSlider = (props: ColorSliderProps, ref: React.Ref): ColorSliderState => { + const state: ColorSliderState = useColorSliderBase_unstable(props, ref); + + // eslint-disable-next-line react-hooks/immutability + state.root['data-channel'] = state.channel; + // eslint-disable-next-line react-hooks/immutability + state.root['data-orientation'] = state.vertical ? 'vertical' : 'horizontal'; + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/index.ts new file mode 100644 index 0000000000000..a52254a277aa3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/index.ts @@ -0,0 +1,19 @@ +export { AlphaSlider, renderAlphaSlider, useAlphaSlider } from './AlphaSlider/index'; +export type { AlphaSliderProps, AlphaSliderSlots, AlphaSliderState } from './AlphaSlider/index'; + +export { ColorArea, renderColorArea, useColorArea } from './ColorArea/index'; +export type { ColorAreaProps, ColorAreaSlots, ColorAreaState } from './ColorArea/index'; + +export { ColorPicker } from './ColorPicker'; +export { renderColorPicker } from './renderColorPicker'; +export { useColorPicker } from './useColorPicker'; +export { useColorPickerContextValues, useColorPickerContextValue } from './useColorPickerContextValues'; +export type { + ColorPickerContextValue, + ColorPickerProps, + ColorPickerSlots, + ColorPickerState, +} from './ColorPicker.types'; + +export { ColorSlider, renderColorSlider, useColorSlider } from './ColorSlider/index'; +export type { ColorSliderProps, ColorSliderSlots, ColorSliderState } from './ColorSlider/index'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/renderColorPicker.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/renderColorPicker.ts new file mode 100644 index 0000000000000..8a6dddeedab37 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/renderColorPicker.ts @@ -0,0 +1 @@ +export { renderColorPicker_unstable as renderColorPicker } from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/useColorPicker.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/useColorPicker.ts new file mode 100644 index 0000000000000..23a7177342951 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/useColorPicker.ts @@ -0,0 +1 @@ +export { useColorPickerBase_unstable as useColorPicker } from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/useColorPickerContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/useColorPickerContextValues.ts new file mode 100644 index 0000000000000..d8225f59999af --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/ColorPicker/useColorPickerContextValues.ts @@ -0,0 +1,4 @@ +export { + useColorPickerContextValues, + useColorPickerContextValue_unstable as useColorPickerContextValue, +} from '@fluentui/react-color-picker'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/AlphaSliderDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/AlphaSliderDefault.stories.tsx new file mode 100644 index 0000000000000..cf31687432800 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/AlphaSliderDefault.stories.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { AlphaSlider } from '@fluentui/react-headless-components-preview/color-picker'; +import { type HSVA, tinycolor } from '@ctrl/tinycolor'; + +import styles from './color-picker.module.css'; + +export const AlphaSliderDefault = (): React.ReactElement => { + const [color, setColor] = React.useState({ h: 320, s: 0.7, v: 0.8, a: 0.75 }); + + return ( +
+ setColor({ a: 1, ...data.color })} + input={{ className: styles.sliderInput }} + rail={{ className: styles.rail }} + thumb={{ className: styles.thumb }} + /> + + +
+ ); +}; + +AlphaSliderDefault.storyName = 'AlphaSlider'; +AlphaSliderDefault.parameters = { + docs: { + description: { + story: + 'An alpha slider component that allows users to select the opacity of a color. The slider displays a gradient from fully transparent to fully opaque, and the thumb can be moved along the slider to adjust the opacity value.', + }, + }, +}; + +const ColorPreview = ({ color }: { color: HSVA }): React.ReactElement => { + return ( +
+ {tinycolor(color).toHex8String()} + + + +
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorAreaDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorAreaDefault.stories.tsx new file mode 100644 index 0000000000000..821232e40e9d7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorAreaDefault.stories.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { ColorArea } from '@fluentui/react-headless-components-preview/color-picker'; +import { type HSVA, tinycolor } from '@ctrl/tinycolor'; + +import styles from './color-picker.module.css'; + +export const ColorAreaDefault = (): React.ReactElement => { + const [color, setColor] = React.useState({ h: 320, s: 0.7, v: 0.8, a: 0.75 }); + + return ( +
+ setColor({ a: 1, ...data.color })} + inputX={{ 'aria-label': 'Saturation', className: styles.areaInput }} + inputY={{ 'aria-label': 'Value', className: styles.areaInput }} + thumb={{ className: styles.areaThumb }} + /> + + +
+ ); +}; + +ColorAreaDefault.storyName = 'ColorArea'; +ColorAreaDefault.parameters = { + docs: { + description: { + story: + 'A color area component that allows users to select the saturation and value of a color. The area displays a gradient representing the range of saturation and value, and the thumb can be moved within the area to adjust these values.', + }, + }, +}; + +const ColorPreview = ({ color }: { color: HSVA }): React.ReactElement => { + return ( +
+ {tinycolor(color).toHex8String()} + + + +
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorPickerDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorPickerDefault.stories.tsx new file mode 100644 index 0000000000000..013b6c8e722ee --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorPickerDefault.stories.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { + AlphaSlider, + ColorArea, + ColorPicker, + ColorSlider, +} from '@fluentui/react-headless-components-preview/color-picker'; +import { type HSVA, tinycolor } from '@ctrl/tinycolor'; + +import styles from './color-picker.module.css'; + +export const ColorPickerDefault = (): React.ReactElement => { + const [color, setColor] = React.useState({ h: 320, s: 0.7, v: 0.8, a: 0.75 }); + + return ( +
+ setColor({ a: 1, ...data.color })} + > + + + + + +
+ ); +}; + +ColorPickerDefault.storyName = 'ColorPicker'; +ColorPickerDefault.parameters = { + docs: { + description: { + story: + 'A color picker component that allows users to select a color using a color area, color sliders, and an alpha slider.', + }, + }, +}; + +const ColorPreview = ({ color }: { color: HSVA }): React.ReactElement => { + return ( +
+ {tinycolor(color).toHex8String()} + + + +
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorPickerDescription.md b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorPickerDescription.md new file mode 100644 index 0000000000000..e606cf448494f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorPickerDescription.md @@ -0,0 +1,3 @@ +A color picker lets users choose a color by adjusting its hue, saturation, value, and alpha channels. + +Use `ColorPicker` to coordinate an HSV color across `ColorArea`, `ColorSlider`, and `AlphaSlider` — it supplies the current color and the change handler to every descendant control. Each of those controls also works on its own with its own controlled or uncontrolled color state, so you can compose only the parts an experience needs, such as a single hue slider. Every control renders a native range input, so give each one an accessible name and, where the raw number is not meaningful, an `aria-valuetext`. diff --git a/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorSliderDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorSliderDefault.stories.tsx new file mode 100644 index 0000000000000..1b689f107edfc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/ColorSliderDefault.stories.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { ColorSlider } from '@fluentui/react-headless-components-preview/color-picker'; +import { type HSVA, tinycolor } from '@ctrl/tinycolor'; + +import styles from './color-picker.module.css'; + +export const ColorSliderDefault = (): React.ReactElement => { + const [color, setColor] = React.useState({ h: 320, s: 0.7, v: 0.8, a: 0.75 }); + + return ( +
+ setColor({ a: 1, ...data.color })} + input={{ className: styles.sliderInput }} + rail={{ className: styles.rail }} + thumb={{ className: styles.thumb }} + /> + + setColor({ a: 1, ...data.color })} + vertical + input={{ className: styles.sliderInput }} + rail={{ className: styles.rail }} + thumb={{ className: styles.thumb }} + /> + +
+ ); +}; + +ColorSliderDefault.storyName = 'ColorSlider'; +ColorSliderDefault.parameters = { + docs: { + description: { + story: + 'A color slider component that allows users to select a color channel (hue, saturation, or value) using a slider control, in horizontal or vertical orientation.', + }, + }, +}; + +const ColorPreview = ({ color }: { color: HSVA }): React.ReactElement => { + return ( +
+ {tinycolor(color).toHex8String()} + + + +
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/color-picker.module.css b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/color-picker.module.css new file mode 100644 index 0000000000000..f2f440e808b46 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/color-picker.module.css @@ -0,0 +1,235 @@ +.card { + display: grid; + gap: var(--space-4); + inline-size: fit-content; + max-inline-size: 100%; + padding: var(--space-4); + color: var(--text); + background: var(--bg-elev); + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2); +} + +/* ColorArea */ + +.area { + position: relative; + display: grid; + align-items: start; + justify-items: start; + aspect-ratio: 1; + inline-size: 100%; + margin-block-end: var(--space-1); + overflow: hidden; + touch-action: none; + background: linear-gradient(to bottom, transparent, #000), linear-gradient(to right, #fff, transparent), + var(--fui-Area--main-color); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-md); +} + +.areaThumb { + position: absolute; + inset-inline-start: var(--fui-AreaX--progress); + inset-block-end: var(--fui-AreaY--progress); + inline-size: var(--space-5); + block-size: var(--space-5); + background: var(--fui-Area__thumb--color); + border: var(--stroke-thin) solid var(--text-faint); + border-radius: var(--radius-pill); + box-shadow: var(--shadow-2); + transform: translate(-50%, 50%); + pointer-events: none; +} + +.areaThumb::before { + position: absolute; + inset: 0; + content: ''; + border: var(--stroke-thick) solid var(--bg-elev); + border-radius: inherit; +} + +.areaInput { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + padding: 0; + margin: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + +.area:has(.areaInput:focus-visible), +.sliderInput:focus-visible { + outline: none; +} + +.area:has(.areaInput:focus-visible) .areaThumb { + border-color: var(--accent); + outline: var(--stroke-thick) solid var(--accent); + outline-offset: var(--stroke-thin); +} + +/* ColorSlider and AlphaSlider */ + +.slider { + --slider-thumb-size: var(--space-5); + --slider-progress: var(--fui-Slider--progress); + --slider-direction: to right; + + position: relative; + display: grid; + align-items: center; + justify-items: center; + touch-action: none; +} + +.alphaSlider { + --slider-progress: var(--fui-AlphaSlider--progress); +} + +/* 3x3 grid: rail and thumb sit in the center cell, the input stretches across all cells */ +.slider[data-orientation='horizontal'] { + min-inline-size: 200px; + min-block-size: var(--space-8); + grid-template-rows: 1fr var(--slider-thumb-size) 1fr; + grid-template-columns: 1fr 100% 1fr; +} + +.slider[data-orientation='vertical'] { + --slider-direction: to top; + + min-inline-size: var(--space-8); + min-block-size: 280px; + grid-template-rows: 1fr 100% 1fr; + grid-template-columns: 1fr var(--slider-thumb-size) 1fr; +} + +.sliderInput { + z-index: 1; + grid-row: 1 / -1; + grid-column: 1 / -1; + inline-size: 100%; + block-size: 100%; + padding: 0; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.slider[data-orientation='vertical'] .sliderInput { + writing-mode: vertical-lr; + direction: rtl; +} + +.rail { + position: relative; + grid-row: 2; + grid-column: 2; + inline-size: 100%; + block-size: 100%; + pointer-events: none; + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-md); +} + +.thumb { + position: absolute; + grid-row: 2; + grid-column: 2; + inline-size: var(--slider-thumb-size); + block-size: var(--slider-thumb-size); + pointer-events: none; + background: var(--fui-Slider__thumb--color); + border: var(--stroke-thin) solid var(--text-faint); + border-radius: var(--radius-pill); + box-shadow: var(--shadow-2); +} + +.thumb::before { + position: absolute; + inset: 0; + content: ''; + border: var(--stroke-thick) solid var(--bg-elev); + border-radius: inherit; +} + +.slider[data-orientation='horizontal'] .thumb { + inset-block: 0; + inset-inline-start: var(--slider-progress); + transform: translateX(-50%); +} + +.slider[data-orientation='vertical'] .thumb { + inset-inline: 0; + inset-block-end: var(--slider-progress); + transform: translateY(50%); +} + +.sliderInput:focus-visible ~ .thumb { + border-color: var(--accent); + outline: var(--stroke-thick) solid var(--accent); + outline-offset: var(--stroke-thin); +} + +.slider[data-channel='hue'] .rail { + background: linear-gradient(var(--fui-Slider--direction), red, fuchsia, blue, aqua, lime, yellow, red); +} + +.slider[data-channel='saturation'] .rail { + background: linear-gradient(var(--slider-direction), #808080, var(--fui-Slider__rail--color)); +} + +.slider[data-channel='value'] .rail { + background: linear-gradient(var(--slider-direction), #000, var(--fui-Slider__rail--color)); +} + +.alphaSlider .rail { + background: linear-gradient(var(--fui-AlphaSlider--direction), transparent, var(--fui-AlphaSlider__rail--color)), + repeating-conic-gradient(var(--border-strong) 0 25%, #fff 0 50%); + background-size: auto, var(--space-4) var(--space-4); +} + +.alphaSlider .thumb { + background: var(--bg-elev); +} + +.alphaSlider .thumb::before { + background: var(--fui-AlphaSlider__thumb--color); +} + +/* Color Preview */ + +.colorPreview { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-2); + align-items: end; + inline-size: 300px; + max-inline-size: 100%; + padding-block-start: var(--space-3); +} + +.output { + display: flex; + align-items: center; + height: 100%; + padding: var(--space-2) var(--space-3); + color: var(--text); + font-family: var(--font-mono); + background: var(--surface-muted); + border: var(--stroke-thin) solid var(--border); + border-radius: var(--radius-sm); +} + +.color { + inline-size: var(--space-12); + block-size: var(--space-12); + border: var(--stroke-thin) solid var(--border-strong); + border-radius: var(--radius-md); + box-shadow: var(--shadow-1); +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/index.stories.tsx new file mode 100644 index 0000000000000..a80b1fbb3d7ec --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/index.stories.tsx @@ -0,0 +1,31 @@ +import { + AlphaSlider, + ColorArea, + ColorPicker, + ColorSlider, +} from '@fluentui/react-headless-components-preview/color-picker'; + +import descriptionMd from './ColorPickerDescription.md'; +import './color-picker.module.css'; + +export { ColorPickerDefault } from './ColorPickerDefault.stories'; +export { ColorAreaDefault } from './ColorAreaDefault.stories'; +export { ColorSliderDefault } from './ColorSliderDefault.stories'; +export { AlphaSliderDefault } from './AlphaSliderDefault.stories'; + +export default { + title: 'Components/ColorPicker', + component: ColorPicker, + subcomponents: { + AlphaSlider, + ColorArea, + ColorSlider, + }, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/yarn.lock b/yarn.lock index 59dca6f0f0833..ff372e8e73a61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4329,6 +4329,7 @@ __metadata: "@fluentui/react-button": "npm:^9.10.1" "@fluentui/react-card": "npm:^9.7.1" "@fluentui/react-checkbox": "npm:^9.6.3" + "@fluentui/react-color-picker": "npm:^9.2.19" "@fluentui/react-combobox": "npm:^9.17.4" "@fluentui/react-context-selector": "npm:^9.2.18" "@fluentui/react-dialog": "npm:^9.18.2"