diff --git a/change/@fluentui-react-headless-components-preview-e1c5f6c2-c62b-4eb3-93dd-d078ced6b1da.json b/change/@fluentui-react-headless-components-preview-e1c5f6c2-c62b-4eb3-93dd-d078ced6b1da.json new file mode 100644 index 00000000000000..29c95b039d012c --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-e1c5f6c2-c62b-4eb3-93dd-d078ced6b1da.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat!: add Combobox filtering and root state attributes", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md index e7c780a581e0ee..1b08ac7323f864 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/combobox.api.md @@ -36,10 +36,12 @@ export { ComboboxSlots } // @public (undocumented) export type ComboboxState = BaseComboboxState & { - input: { - 'data-state'?: 'open' | 'closed'; + root: { + 'data-open'?: string; 'data-disabled'?: string; 'data-placeholder'?: string; + 'data-invalid'?: string; + 'data-clearable'?: string; }; }; @@ -95,6 +97,9 @@ export const useCombobox: (props: ComboboxProps, ref: React_2.Ref ComboboxContextValues; +// @public +export function useComboboxFilter({ filter: filterOverride, noOptionsElement, renderOption, query, options, }: UseComboboxFilterConfig): JSXElement[]; + // @public export const useListbox: (props: ListboxProps, ref: React_2.Ref) => ListboxState; diff --git a/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md b/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md index bb20be7b65a9c1..0112da61c4ff78 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/dropdown.api.md @@ -33,14 +33,12 @@ export type DropdownProps = Omit) => TagPickerControlState; -// @public (undocumented) -export function useTagPickerFilter({ filter: filterOverride, noOptionsElement, renderOption, query, options, }: UseTagPickerFilterConfig): JSXElement[]; +// @public +export function useTagPickerFilter({ filter: filterOverride, noOptionsElement, renderOption, query, options, }: UseComboboxFilterConfig): JSXElement[]; // @public export const useTagPickerGroup: (props: TagPickerGroupProps, ref: React_2.Ref) => TagPickerGroupState; diff --git a/packages/react-components/react-headless-components-preview/library/src/combobox.ts b/packages/react-components/react-headless-components-preview/library/src/combobox.ts index 21e2c75cef4a50..d0484eb340470d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/combobox.ts +++ b/packages/react-components/react-headless-components-preview/library/src/combobox.ts @@ -3,6 +3,7 @@ export { renderCombobox, useCombobox, useComboboxContextValues, + useComboboxFilter, Listbox, renderListbox, useListbox, diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.types.ts index e4ad34ba136697..9c80757beb825a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/Combobox.types.ts @@ -10,18 +10,26 @@ export type { export type ComboboxProps = Omit; export type ComboboxState = BaseComboboxState & { - input: { + root: { /** - * Whether the combobox is currently open. + * Whether the dropdown is currently open. */ - 'data-state'?: 'open' | 'closed'; + 'data-open'?: string; /** - * Whether the input element is currently disabled. + * Whether the trigger element is currently disabled. */ 'data-disabled'?: string; /** - * Whether the input element is currently displaying a placeholder. + * Whether the trigger element is currently displaying a placeholder. */ 'data-placeholder'?: string; + /** + * Whether the trigger element is currently invalid. + */ + 'data-invalid'?: string; + /** + * Wether the clear icon is visible. + */ + 'data-clearable'?: string; }; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/index.ts index e36186c347dcdf..bd38f7689a3cb9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/index.ts @@ -3,6 +3,7 @@ export type { ComboboxSlots, ComboboxProps, ComboboxState } from './Combobox.typ export { renderCombobox } from './renderCombobox'; export { useCombobox } from './useCombobox'; export { useComboboxContextValues } from './useComboboxContextValues'; +export { useComboboxFilter } from './useComboboxFilter'; export { Listbox, renderListbox, useListbox, useListboxContextValues } from '../Dropdown/Listbox'; export type { ListboxSlots, ListboxProps, ListboxState, ListboxContextValues } from '../Dropdown/Listbox'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts index 88cfb273951b94..1df736f492c88a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useCombobox.ts @@ -44,16 +44,20 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref 0 && !disabled && clearable && !multiselect; + const placeholderVisible = !baseState.value && !!mergedProps.placeholder; const state: ComboboxState = { + ...baseState, components: { root: 'div', input: 'input', expandIcon: 'span', clearIcon: 'span', listbox: Listbox }, - root: rootSlot, - input: { - ...triggerSlot, - 'data-state': open ? 'open' : 'closed', + root: { + ...rootSlot, + 'data-open': stringifyDataAttribute(open), 'data-disabled': stringifyDataAttribute(triggerSlot.disabled), - 'data-placeholder': stringifyDataAttribute(!baseState.value), + 'data-placeholder': stringifyDataAttribute(placeholderVisible), + 'data-invalid': stringifyDataAttribute(triggerSlot['aria-invalid']), + 'data-clearable': stringifyDataAttribute(showClearIcon), }, + input: triggerSlot, listbox: open || hasFocus ? listbox : undefined, clearIcon: slot.optional(mergedProps.clearIcon, { defaultProps: { 'aria-hidden': 'true' }, @@ -66,7 +70,6 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref { + const noOptionsElement = ; + const renderOption = (option: string) => ( + + ); + + it('renders headless Option elements by default', () => { + const { result } = renderHook(() => + useComboboxFilter({ + query: '', + options: ['Cat'], + noOptionsElement, + renderOption, + }), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + type: Option, + props: { value: 'Cat', children: 'Cat' }, + }); + }); + + it('filters options using the query by default', () => { + const { result } = renderHook(() => + useComboboxFilter({ + query: 'at', + options: ['Cat', 'Dog'], + noOptionsElement, + renderOption, + }), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].props.value).toBe('Cat'); + }); + + it('forwards option indexes to a caller-provided filter', () => { + const filter = jest.fn((_option: string, index: number) => index === 1); + + const { result } = renderHook(() => + useComboboxFilter({ + query: '', + options: ['Cat', 'Dog'], + filter, + noOptionsElement, + renderOption, + }), + ); + + expect(filter).toHaveBeenNthCalledWith(1, 'Cat', 0); + expect(filter).toHaveBeenNthCalledWith(2, 'Dog', 1); + expect(result.current[0].props.value).toBe('Dog'); + }); + + it('adds a stable key to an unkeyed no-options element', () => { + const { result } = renderHook(() => + useComboboxFilter({ + query: '', + options: [], + noOptionsElement, + renderOption, + }), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].key).toBe('no-options'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useComboboxFilter.tsx similarity index 55% rename from packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.tsx rename to packages/react-components/react-headless-components-preview/library/src/components/Combobox/useComboboxFilter.tsx index e559eb0516ca9d..ecefc146bef559 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Combobox/useComboboxFilter.tsx @@ -3,31 +3,43 @@ import * as React from 'react'; import type { JSXElement } from '@fluentui/react-utilities'; -import { TagPickerOption } from './TagPickerOption'; - -type UseTagPickerFilterConfig = { +type UseComboboxFilterConfig = { + /** + * The current query string used to filter the options. + */ query: string; + /** + * The list of options to filter. + */ options: string[]; + /** + * Optional filter function to override the default filtering behavior. + */ filter?: (option: string, index: number) => boolean; + /** + * Element to render when there are no options to display. + */ noOptionsElement: JSXElement; - renderOption?: (option: string) => JSXElement; + /** + * A function that renders an option element for a given option. + * Use it to customize how options are displayed in the listbox. + */ + renderOption: (option: string) => JSXElement; }; -function defaultRenderOption(option: string): JSXElement { - return ( - - {option} - - ); -} - -export function useTagPickerFilter({ +/** + * A hook that filters a list of options based on a query string and returns the filtered options as JSX elements. + * + * @param config - The configuration object for the hook. + * @returns An array of JSX elements representing the filtered options. + */ +export function useComboboxFilter({ filter: filterOverride, noOptionsElement, - renderOption = defaultRenderOption, + renderOption, query, options, -}: UseTagPickerFilterConfig): JSXElement[] { +}: UseComboboxFilterConfig): JSXElement[] { const defaultFilter = React.useCallback( (option: string) => { const trimmedQuery = query.trim(); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Dropdown.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Dropdown.types.ts index a1466f752bc5cf..f05547444ee943 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Dropdown.types.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Dropdown.types.ts @@ -10,11 +10,11 @@ export type { export type DropdownProps = Omit; export type DropdownState = DropdownBaseHookState & { - button: DropdownBaseHookState['button'] & { + root: DropdownBaseHookState['root'] & { /** * Whether the dropdown is currently open. */ - 'data-state'?: 'open' | 'closed'; + 'data-open'?: string; /** * Whether the trigger element is currently disabled. */ @@ -27,14 +27,9 @@ export type DropdownState = DropdownBaseHookState & { * Whether the trigger element is currently invalid. */ 'data-invalid'?: string; - }; - /** - * The resolved clear button slot state. - */ - clearButton?: DropdownBaseHookState['clearButton'] & { /** - * Whether the clear button is currently visible. + * Wether the clear icon is visible. */ - 'data-visible'?: string; + 'data-clearable'?: string; }; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Option/useOption.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Option/useOption.ts index df9fad5ce708e2..3201811704848a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Option/useOption.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/Option/useOption.ts @@ -11,14 +11,17 @@ import { stringifyDataAttribute } from '../../../utils/stringifyDataAttribute'; * The returned state can be modified with hooks before being passed to `renderOption`. */ export const useOption = (props: OptionProps, ref: React.Ref): OptionState => { - const state: OptionState = useOptionBase_unstable(props, ref); + const baseState = useOptionBase_unstable(props, ref); - // eslint-disable-next-line react-hooks/immutability - state.root['data-disabled'] = stringifyDataAttribute(state.disabled); - // eslint-disable-next-line react-hooks/immutability - state.root['data-selected'] = stringifyDataAttribute(state.selected); - // eslint-disable-next-line react-hooks/immutability - state.root['data-multiselect'] = stringifyDataAttribute(state.multiselect); + const state: OptionState = { + ...baseState, + root: { + ...baseState.root, + 'data-disabled': stringifyDataAttribute(baseState.disabled), + 'data-selected': stringifyDataAttribute(baseState.selected), + 'data-multiselect': stringifyDataAttribute(baseState.multiselect), + }, + }; return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useDropdown.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useDropdown.ts index 79a41eca595931..7c65d667739575 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useDropdown.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useDropdown.ts @@ -52,16 +52,24 @@ export const useDropdown = (props: DropdownProps, ref: React.Ref 0 && !disabled && clearable && !multiselect; + const placeholderVisible = !baseState.value && !!mergedProps.placeholder; const state: DropdownState = { - components: { root: 'div', button: 'button', clearButton: 'button', expandIcon: 'span', listbox: Listbox }, - root: rootSlot, - button: { - ...trigger, - 'data-state': open ? 'open' : 'closed', + components: { + root: 'div', + button: 'button', + clearButton: 'button', + expandIcon: 'span', + listbox: Listbox, + }, + root: { + ...rootSlot, + 'data-open': stringifyDataAttribute(open), 'data-disabled': stringifyDataAttribute(trigger.disabled), - 'data-placeholder': stringifyDataAttribute(!baseState.value), + 'data-placeholder': stringifyDataAttribute(placeholderVisible), 'data-invalid': stringifyDataAttribute(trigger['aria-invalid']), + 'data-clearable': stringifyDataAttribute(showClearButton), }, + button: trigger, listbox: open || hasFocus ? listbox : undefined, clearButton: slot.optional(mergedProps.clearButton, { defaultProps: { @@ -78,7 +86,7 @@ export const useDropdown = (props: DropdownProps, ref: React.Ref { - it('renders headless TagPickerOption elements by default', () => { - const noOptionsElement = No options; - const { result } = renderHook(() => - useTagPickerFilter({ - query: '', - options: ['Cat'], - noOptionsElement, - }), - ); - - expect(result.current[0].type).toBe(TagPickerOption); - }); - - it('uses a caller-provided option renderer', () => { - const noOptionsElement = No options; - const renderOption = (option: string) => Custom {option}; - const { result } = renderHook(() => - useTagPickerFilter({ - query: '', - options: ['Cat'], - noOptionsElement, - renderOption, - }), - ); - - expect(result.current[0].type).toBe('span'); - expect(result.current[0].key).toBe('Cat'); - expect(React.Children.toArray(result.current[0].props.children).join('')).toBe('Custom Cat'); - }); - - it('filters options using the query by default', () => { - const noOptionsElement = No options; - const { result } = renderHook(() => - useTagPickerFilter({ - query: 'at', - options: ['Cat', 'Dog'], - noOptionsElement, - }), - ); - - expect(result.current).toHaveLength(1); - expect(result.current[0].props.value).toBe('Cat'); - }); - - it('forwards option indexes to a caller-provided filter', () => { - const filter = jest.fn((_option: string, index: number) => index === 1); - const noOptionsElement = No options; - const { result } = renderHook(() => - useTagPickerFilter({ - query: '', - options: ['Cat', 'Dog'], - filter, - noOptionsElement, - }), - ); - - expect(filter).toHaveBeenNthCalledWith(1, 'Cat', 0); - expect(filter).toHaveBeenNthCalledWith(2, 'Dog', 1); - expect(result.current[0].props.value).toBe('Dog'); - }); - - it('adds a stable key to an unkeyed no-options element', () => { - const noOptionsElement = No options; - const { result } = renderHook(() => - useTagPickerFilter({ - query: '', - options: [], - noOptionsElement, - }), - ); - - expect(result.current).toHaveLength(1); - expect(result.current[0].key).toBe('no-options'); - }); - - it('preserves the key of a keyed no-options element', () => { - const noOptionsElement = No options; - const { result } = renderHook(() => - useTagPickerFilter({ - query: '', - options: [], - noOptionsElement, - }), - ); - - expect(result.current).toHaveLength(1); - expect(result.current[0]).toBe(noOptionsElement); - expect(result.current[0].key).toBe('custom-no-options'); - }); -}); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Combobox/ComboboxFiltering.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Combobox/ComboboxFiltering.stories.tsx new file mode 100644 index 00000000000000..baab2de2835b6f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Combobox/ComboboxFiltering.stories.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { Combobox, Option, useComboboxFilter } from '@fluentui/react-headless-components-preview/combobox'; +import type { ComboboxProps } from '@fluentui/react-headless-components-preview/combobox'; +import { CheckmarkRegular, ChevronDownRegular } from '@fluentui/react-icons'; +import styles from './combobox.module.css'; + +const options = ['Cat', 'Caterpillar', 'Corgi', 'Chupacabra', 'Dog', 'Ferret', 'Fish', 'Fox', 'Hamster', 'Snake']; + +export const Filtering = (): React.ReactNode => { + const [query, setQuery] = React.useState(''); + + const children = useComboboxFilter({ + query, + options, + noOptionsElement: ( + + ), + renderOption: option => ( + + ), + }); + + const onOptionSelect: ComboboxProps['onOptionSelect'] = (_event, data) => { + setQuery(data.optionText ?? ''); + }; + + return ( +
+ + setQuery(event.target.value)} + onOptionSelect={onOptionSelect} + expandIcon={{ className: styles.expandIcon, children: }} + > + {children} + +
+ ); +}; + +Filtering.parameters = { + docs: { + description: { + story: 'The `useComboboxFilter` hook filters options by the typed query and renders headless options.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Combobox/combobox.module.css b/packages/react-components/react-headless-components-preview/stories/src/Combobox/combobox.module.css index 63b178fe62456d..7c14962b00c122 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Combobox/combobox.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Combobox/combobox.module.css @@ -10,6 +10,7 @@ .root { display: flex; align-items: center; + gap: var(--space-2); width: 100%; border-radius: var(--radius-md); border: var(--stroke-thin) solid var(--border); @@ -99,6 +100,7 @@ display: inline-flex; align-items: center; justify-content: center; + margin-inline-start: auto; visibility: hidden; } @@ -134,7 +136,7 @@ /* clear icon — inline span acting as a clickable affordance */ .clearIcon { - display: inline-flex; + display: none; align-items: center; justify-content: center; width: 20px; @@ -151,8 +153,10 @@ color: var(--text); } -.input[data-placeholder] + .clearIcon, -.input:not([data-placeholder]) ~ .expandIcon { +.root[data-clearable] .clearIcon { + display: inline-flex; +} +.root[data-clearable] .expandIcon { display: none; } @@ -181,6 +185,17 @@ text-transform: uppercase; } +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + width: 16px; + height: 16px; + } +} + /* Demo helpers */ .demo { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Combobox/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Combobox/index.stories.tsx index d8d4a178e01cb2..63643f8fc9f2bb 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Combobox/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Combobox/index.stories.tsx @@ -5,6 +5,7 @@ import descriptionMd from './ComboboxDescription.md'; import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; export { Default } from './ComboboxDefault.stories'; +export { Filtering } from './ComboboxFiltering.stories'; export { Controlled } from './ComboboxControlled.stories'; export { Freeform } from './ComboboxFreeform.stories'; export { Grouped } from './ComboboxGrouped.stories'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Dropdown/dropdown.module.css b/packages/react-components/react-headless-components-preview/stories/src/Dropdown/dropdown.module.css index 6d7261e09a4a06..1f92f532ac1087 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Dropdown/dropdown.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Dropdown/dropdown.module.css @@ -11,7 +11,7 @@ min-width: 0; width: 100%; display: flex; - justify-content: space-between; + gap: var(--space-2); align-items: center; border-radius: var(--radius-md); border: var(--stroke-thin) solid var(--border); @@ -41,7 +41,7 @@ box-shadow: 0 0 0 3px var(--accent-soft); } -.button[data-state='open'] { +[data-open] > .button { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } @@ -100,6 +100,7 @@ display: inline-flex; align-items: center; justify-content: center; + margin-inline-start: auto; visibility: hidden; } @@ -127,6 +128,7 @@ display: inline-flex; align-items: center; justify-content: center; + margin-inline-start: auto; width: 16px; height: 16px; flex-shrink: 0; @@ -139,7 +141,7 @@ right: var(--space-2); top: 50%; transform: translateY(-50%); - display: inline-flex; + display: none; align-items: center; justify-content: center; border-radius: var(--radius-pill); @@ -162,8 +164,11 @@ box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); } -.button[data-placeholder] + .clearButton, -.button:not([data-placeholder]) .expandIcon { +[data-clearable] > .clearButton { + display: inline-flex; +} + +[data-clearable] > .button .expandIcon { display: none; } @@ -192,6 +197,17 @@ text-transform: uppercase; } +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + width: 16px; + height: 16px; + } +} + /* Demo helpers (used by Storybook examples) */ .demo {