Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};

Expand Down Expand Up @@ -95,6 +97,9 @@ export const useCombobox: (props: ComboboxProps, ref: React_2.Ref<HTMLInputEleme
// @public (undocumented)
export const useComboboxContextValues: (state: ComboboxState) => ComboboxContextValues;

// @public
export function useComboboxFilter({ filter: filterOverride, noOptionsElement, renderOption, query, options, }: UseComboboxFilterConfig): JSXElement[];

// @public
export const useListbox: (props: ListboxProps, ref: React_2.Ref<HTMLElement>) => ListboxState;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,12 @@ export type DropdownProps = Omit<DropdownBaseHookProps, 'inlinePopup' | 'mountNo

// @public (undocumented)
export type DropdownState = DropdownBaseHookState & {
button: DropdownBaseHookState['button'] & {
'data-state'?: 'open' | 'closed';
root: DropdownBaseHookState['root'] & {
'data-open'?: string;
'data-disabled'?: string;
'data-placeholder'?: string;
'data-invalid'?: string;
};
clearButton?: DropdownBaseHookState['clearButton'] & {
'data-visible'?: string;
'data-clearable'?: string;
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ export { useTagPickerContextValues }
// @public
export const useTagPickerControl: (props: TagPickerControlProps, ref: React_2.Ref<HTMLDivElement>) => 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<HTMLDivElement>) => TagPickerGroupState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
renderCombobox,
useCombobox,
useComboboxContextValues,
useComboboxFilter,
Listbox,
renderListbox,
useListbox,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,26 @@ export type {
export type ComboboxProps = Omit<BaseComboboxProps, 'inlinePopup' | 'mountNode'>;

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;
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,20 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref<HTMLInputElemen
});

const showClearIcon = selectedOptions.length > 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' },
Expand All @@ -66,7 +70,6 @@ export const useCombobox = (props: ComboboxProps, ref: React.Ref<HTMLInputElemen
}),
showClearIcon,
activeDescendantController,
...baseState,
};

const onClearIconMouseDown = useEventCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as React from 'react';
import { renderHook } from '@testing-library/react-hooks';

import { useComboboxFilter } from './useComboboxFilter';
import { Option } from '../Dropdown/Option';

describe('useComboboxFilter', () => {
const noOptionsElement = <Option value="no-options">No options</Option>;
const renderOption = (option: string) => (
<Option key={option} value={option}>
{option}
</Option>
);

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TagPickerOption value={option} key={option}>
{option}
</TagPickerOption>
);
}

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ export type {
export type DropdownProps = Omit<DropdownBaseHookProps, 'inlinePopup' | 'mountNode'>;

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.
*/
Expand All @@ -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;
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>): 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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,24 @@ export const useDropdown = (props: DropdownProps, ref: React.Ref<HTMLButtonEleme
});

const showClearButton = selectedOptions.length > 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: {
Expand All @@ -78,7 +86,7 @@ export const useDropdown = (props: DropdownProps, ref: React.Ref<HTMLButtonEleme
renderByDefault: true,
elementType: 'span',
}),
placeholderVisible: !baseState.value && !!mergedProps.placeholder,
placeholderVisible,
showClearButton,
activeDescendantController,
...baseState,
Expand All @@ -94,7 +102,6 @@ export const useDropdown = (props: DropdownProps, ref: React.Ref<HTMLButtonEleme

if (state.clearButton) {
state.clearButton.onClick = onClearButtonClick;
state.clearButton['data-visible'] = stringifyDataAttribute(showClearButton);
}

// Heads up! We don't support "clearable" in multiselect mode, so we should never display a slot
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { TagPicker } from './TagPicker';
export { renderTagPicker } from './renderTagPicker';
export { useTagPicker } from './useTagPicker';
export { useTagPickerFilter } from './useTagPickerFilter';
export { useComboboxFilter as useTagPickerFilter } from '../Combobox/useComboboxFilter';
export { useTagPickerContextValues } from '@fluentui/react-tag-picker';
export type {
TagPickerProps,
Expand Down
Loading
Loading