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 00000000000000..30194908b3c913 --- /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/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 00000000000000..7f6acec5b5a9be --- /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/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 00000000000000..914e55331c8de4 --- /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/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 00000000000000..e99c088b374cd3 --- /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-button/library/src/components/SplitButton/SplitButton.test.tsx b/packages/react-components/react-button/library/src/components/SplitButton/SplitButton.test.tsx index 0b0401b59ef0d8..641b1502cb3765 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); + }); }); 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 ad703e55a1e677..8150bb0cb6b9a4 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 42d1552ee9bb72..995e03f356b10c 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 ee9f5325048967..27e2111d062f4a 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 16e6329cf3ca2e..b3c0abd0f45efd 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 eee7bc5a3cb775..41fda287cd1767 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 2d93d5cec854f3..593387140a4247 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 5e402c922c3506..5cea7ec82c7e56 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 1654f849f5fd9d..e9d2862a608be8 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.test.tsx b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.test.tsx new file mode 100644 index 00000000000000..eb9e7bbbbc6f73 --- /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/AlphaSlider/useAlphaSlider.ts b/packages/react-components/react-color-picker/library/src/components/AlphaSlider/useAlphaSlider.ts index 468fc36d0687c5..91c74365293d1b 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 922f1782bcec1f..d3d7db16516027 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 eafdc6bbffe238..a08b9396e33530 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 f80addadcf3bf8..7ab3aada88a755 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.test.tsx b/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.test.tsx new file mode 100644 index 00000000000000..2fa02126c956ac --- /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/ColorArea/useColorArea.ts b/packages/react-components/react-color-picker/library/src/components/ColorArea/useColorArea.ts index 66574f39be72c3..7a4bda30490f84 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 88db0e86242ccf..6c720014b6bfef 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 f85f52b20eca38..1a2740e7fc72e7 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 882b6a4fabb3d2..53aa154dbb1bac 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.test.tsx b/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.test.tsx new file mode 100644 index 00000000000000..bb0432940f4bd9 --- /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/ColorPicker/useColorPicker.ts b/packages/react-components/react-color-picker/library/src/components/ColorPicker/useColorPicker.ts index ed7816a8f308bd..d0b0188f0c5973 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 4bcc2c961e741f..749ac73fb35e79 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 5c40e90d4f7190..337e1fe684a39b 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 822748e81e342b..806cf8667bb31b 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 2d1ccfe16a618a..44e40281a39356 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.test.tsx b/packages/react-components/react-color-picker/library/src/components/ColorSlider/useColorSlider.test.tsx new file mode 100644 index 00000000000000..95f52ffc83e1a0 --- /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'); + }); +}); 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 9d5344a85b3f0e..612c2711416925 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 05ff72f1c41175..0afbf9297b027d 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'; diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index e019dfd88c27a9..d2b7af34c33e3d 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,7 +6,9 @@ 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'; import * as Divider from '@fluentui/react-headless-components-preview/divider'; import * as Drawer from '@fluentui/react-headless-components-preview/drawer'; @@ -56,7 +58,9 @@ console.log({ Button, Card, Checkbox, + ColorPicker, Combobox, + CompoundButton, Dialog, Divider, Drawer, 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 00000000000000..d29a3dd5b7002b --- /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/etc/compound-button.api.md b/packages/react-components/react-headless-components-preview/library/etc/compound-button.api.md new file mode 100644 index 00000000000000..a173707e89ebc7 --- /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 5ea5c4f77d04af..2a32c5be1b2178 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,12 +125,24 @@ "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", "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/color-picker.ts b/packages/react-components/react-headless-components-preview/library/src/color-picker.ts new file mode 100644 index 00000000000000..8c48ba3e7e3189 --- /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 00000000000000..1ac4824af83b45 --- /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 00000000000000..9f65f2e5d5cc72 --- /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 00000000000000..739c610cf3fabd --- /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 00000000000000..4836104f24c3d4 --- /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 00000000000000..1ad598faef67e5 --- /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 00000000000000..3ec6cb37a281df --- /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 00000000000000..9f825faa0c6251 --- /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 00000000000000..cc8987c9cbd110 --- /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 00000000000000..371c5ebec58cb2 --- /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 00000000000000..625654a5c1a483 --- /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 00000000000000..daf5559e6121d3 --- /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 00000000000000..5a98544c00d2d4 --- /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 00000000000000..cfe3d268026c7a --- /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 00000000000000..939d464328637f --- /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 00000000000000..6ddaf23e2ae986 --- /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 00000000000000..f244d7fc0284bd --- /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 00000000000000..5c018142988b3c --- /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 00000000000000..1411051b0ad5bb --- /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 00000000000000..c3722c93270a59 --- /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 00000000000000..a871471009f197 --- /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 00000000000000..ee409a03173896 --- /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 00000000000000..a52254a277aa3a --- /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 00000000000000..8a6dddeedab37b --- /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 00000000000000..23a71773429511 --- /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 00000000000000..d8225f59999af0 --- /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/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 00000000000000..4c313a0857ef01 --- /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 00000000000000..0f99de5ba312a0 --- /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 00000000000000..50c367ba493c3b --- /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 00000000000000..c3472c6e0bc91c --- /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 00000000000000..76394ace4f0942 --- /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 00000000000000..878c5edbc7a6ad --- /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 00000000000000..cfeb2db8705e31 --- /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/ColorPicker/AlphaSliderDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/ColorPicker/AlphaSliderDefault.stories.tsx new file mode 100644 index 00000000000000..cf316874328005 --- /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 00000000000000..821232e40e9d72 --- /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 00000000000000..013b6c8e722eed --- /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 00000000000000..e606cf448494f3 --- /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 00000000000000..1b689f107edfc7 --- /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 00000000000000..f2f440e808b46a --- /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 00000000000000..a80b1fbb3d7ec5 --- /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/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 00000000000000..a00146d3c67681 --- /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 00000000000000..261a61aa38a4e3 --- /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 00000000000000..36051afcac3ca5 --- /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 00000000000000..42be7f8c2092b3 --- /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, + }, + }, + }, +}; diff --git a/yarn.lock b/yarn.lock index 59dca6f0f0833c..ff372e8e73a616 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"