diff --git a/change/@fluentui-react-headless-components-preview-7694d78b-9ac6-40a0-8490-31128315e61a.json b/change/@fluentui-react-headless-components-preview-7694d78b-9ac6-40a0-8490-31128315e61a.json new file mode 100644 index 0000000000000..c6da07f80009f --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-7694d78b-9ac6-40a0-8490-31128315e61a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "feat: add headless TagPicker", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/AllComponents.fixture.js index 81812ffed92a0..7603f711eecce 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 @@ -37,6 +37,7 @@ import * as Switch from '@fluentui/react-headless-components-preview/switch'; import * as TabList from '@fluentui/react-headless-components-preview/tab-list'; import * as Tag from '@fluentui/react-headless-components-preview/tag'; import * as TagGroup from '@fluentui/react-headless-components-preview/tag-group'; +import * as TagPicker from '@fluentui/react-headless-components-preview/tag-picker'; import * as TeachingPopover from '@fluentui/react-headless-components-preview/teaching-popover'; import * as Textarea from '@fluentui/react-headless-components-preview/textarea'; import * as Toast from '@fluentui/react-headless-components-preview/toast'; @@ -84,6 +85,7 @@ console.log({ TabList, Tag, TagGroup, + TagPicker, TeachingPopover, Textarea, Toast, diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md new file mode 100644 index 0000000000000..2088df6a6c965 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md @@ -0,0 +1,238 @@ +## 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 { ComponentProps } from '@fluentui/react-utilities'; +import type { ComponentState } from '@fluentui/react-utilities'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { JSXElement } from '@fluentui/react-utilities'; +import type { ListboxProps as ListboxProps_2 } from '@fluentui/react-combobox'; +import type { OptionGroupProps } from '@fluentui/react-combobox'; +import type { OptionGroupSlots } from '@fluentui/react-combobox'; +import { OptionGroupState } from '@fluentui/react-combobox'; +import type { OptionProps as OptionProps_2 } from '@fluentui/react-combobox'; +import type { OptionSlots as OptionSlots_2 } from '@fluentui/react-combobox'; +import type { OptionState as OptionState_2 } from '@fluentui/react-combobox'; +import type * as React_2 from 'react'; +import { renderTagPicker_unstable as renderTagPicker } from '@fluentui/react-tag-picker'; +import type { Slot } from '@fluentui/react-utilities'; +import type { TagGroupBaseState } from '@fluentui/react-tags'; +import type { TagGroupContextValues } from '@fluentui/react-tags'; +import type { TagPickerButtonBaseState } from '@fluentui/react-tag-picker'; +import { TagPickerButtonBaseProps as TagPickerButtonProps } from '@fluentui/react-tag-picker'; +import { TagPickerButtonSlots } from '@fluentui/react-tag-picker'; +import { TagPickerContextValue } from '@fluentui/react-tag-picker'; +import { TagPickerContextValues } from '@fluentui/react-tag-picker'; +import type { TagPickerControlBaseState } from '@fluentui/react-tag-picker'; +import { TagPickerControlProps } from '@fluentui/react-tag-picker'; +import { TagPickerControlSlots } from '@fluentui/react-tag-picker'; +import type { TagPickerGroupSlots } from '@fluentui/react-tag-picker'; +import type { TagPickerInputBaseState } from '@fluentui/react-tag-picker'; +import { TagPickerInputBaseProps as TagPickerInputProps } from '@fluentui/react-tag-picker'; +import { TagPickerInputSlots } from '@fluentui/react-tag-picker'; +import { TagPickerOnOpenChangeData } from '@fluentui/react-tag-picker'; +import { TagPickerOnOptionSelectData } from '@fluentui/react-tag-picker'; +import type { TagPickerProps as TagPickerProps_2 } from '@fluentui/react-tag-picker'; +import { TagPickerSize } from '@fluentui/react-tag-picker'; +import { TagPickerSlots } from '@fluentui/react-tag-picker'; +import { TagPickerState } from '@fluentui/react-tag-picker'; +import { useTagPickerContext_unstable } from '@fluentui/react-tag-picker'; +import { useTagPickerFilter } from '@fluentui/react-tag-picker'; + +export { renderTagPicker } + +// @public +export const renderTagPickerButton: (state: TagPickerButtonState) => JSXElement; + +// @public +export const renderTagPickerControl: (state: TagPickerControlState) => JSXElement; + +// @public +export const renderTagPickerGroup: (state: TagPickerGroupState, contextValues: TagGroupContextValues) => JSXElement | null; + +// @public +export const renderTagPickerInput: (state: TagPickerInputState) => JSXElement; + +// @public +export const renderTagPickerList: (state: TagPickerListState) => JSXElement; + +// @public +export const renderTagPickerOption: (state: TagPickerOptionState) => JSXElement; + +// @public +export const renderTagPickerOptionGroup: (state: OptionGroupState) => JSXElement; + +// @public (undocumented) +export const TagPicker: ForwardRefComponent; + +// @public +export const TagPickerButton: ForwardRefComponent; + +export { TagPickerButtonProps } + +export { TagPickerButtonSlots } + +// @public +export type TagPickerButtonState = TagPickerButtonBaseState & { + root: { + 'data-disabled'?: string; + }; +}; + +export { TagPickerContextValue } + +export { TagPickerContextValues } + +// @public +export const TagPickerControl: ForwardRefComponent; + +// @public +export type TagPickerControlInternalSlots = { + aside?: NonNullable>; +}; + +export { TagPickerControlProps } + +export { TagPickerControlSlots } + +// @public +export type TagPickerControlState = TagPickerControlBaseState & { + root: { + 'data-disabled'?: string; + 'data-invalid'?: string; + }; +}; + +// @public +export const TagPickerGroup: ForwardRefComponent; + +// @public +export type TagPickerGroupProps = ComponentProps; + +export { TagPickerGroupSlots } + +// @public +export type TagPickerGroupState = TagGroupBaseState & { + hasSelectedOptions: boolean; + root: { + focusgroup?: string; + 'data-disabled'?: string; + }; +}; + +// @public +export const TagPickerInput: ForwardRefComponent; + +export { TagPickerInputProps } + +export { TagPickerInputSlots } + +// @public +export type TagPickerInputState = TagPickerInputBaseState & { + root: { + 'data-disabled'?: string; + }; +}; + +// @public +export const TagPickerList: ForwardRefComponent; + +// @public +export type TagPickerListProps = ComponentProps; + +// @public (undocumented) +export type TagPickerListSlots = { + root: Slot; +}; + +// @public +export type TagPickerListState = ComponentState & { + open: boolean; +}; + +export { TagPickerOnOpenChangeData } + +export { TagPickerOnOptionSelectData } + +// @public +export const TagPickerOption: ForwardRefComponent; + +// @public +export const TagPickerOptionGroup: ForwardRefComponent; + +// @public +export type TagPickerOptionGroupProps = OptionGroupProps; + +// @public (undocumented) +export type TagPickerOptionGroupSlots = OptionGroupSlots; + +// @public +export type TagPickerOptionGroupState = OptionGroupState; + +// @public +export type TagPickerOptionProps = OptionProps & { + media?: Slot<'span'>; + secondaryContent?: Slot<'span'>; +}; + +// @public (undocumented) +export type TagPickerOptionSlots = OptionSlots & { + media?: Slot<'span'>; + secondaryContent?: Slot<'span'>; +}; + +// @public +export type TagPickerOptionState = OptionState & { + components: OptionState['components'] & { + media: 'span'; + secondaryContent: 'span'; + }; + media?: Slot<'span'>; + secondaryContent?: Slot<'span'>; +}; + +// @public (undocumented) +export type TagPickerProps = Omit; + +export { TagPickerSize } + +export { TagPickerSlots } + +export { TagPickerState } + +// @public +export const useTagPicker: (props: TagPickerProps) => TagPickerState; + +// @public +export const useTagPickerButton: (props: TagPickerButtonProps, ref: React_2.Ref) => TagPickerButtonState; + +export { useTagPickerContext_unstable } + +// @public +export function useTagPickerContextValues(state: TagPickerState): TagPickerContextValues; + +// @public +export const useTagPickerControl: (props: TagPickerControlProps, ref: React_2.Ref) => TagPickerControlState; + +export { useTagPickerFilter } + +// @public +export const useTagPickerGroup: (props: TagPickerGroupProps, ref: React_2.Ref) => TagPickerGroupState; + +// @public +export const useTagPickerInput: (props: TagPickerInputProps, ref: React_2.Ref) => TagPickerInputState; + +// @public +export const useTagPickerList: (props: TagPickerListProps, ref: React_2.Ref) => TagPickerListState; + +// @public +export const useTagPickerOption: (props: TagPickerOptionProps, ref: React_2.Ref) => TagPickerOptionState; + +// @public +export const useTagPickerOptionGroup: (props: TagPickerOptionGroupProps, ref: React_2.Ref) => TagPickerOptionGroupState; + +// (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 2a59078740ca0..c439846b29aaa 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -58,6 +58,7 @@ "@fluentui/react-switch": "^9.7.3", "@fluentui/react-tabs": "^9.12.2", "@fluentui/react-tabster": "^9.26.15", + "@fluentui/react-tag-picker": "^9.8.8", "@fluentui/react-tags": "^9.9.1", "@fluentui/react-textarea": "^9.7.3", "@fluentui/react-toolbar": "^9.8.1", @@ -313,6 +314,12 @@ "import": "./lib/tag-group.js", "require": "./lib-commonjs/tag-group.js" }, + "./tag-picker": { + "types": "./dist/tag-picker.d.ts", + "node": "./lib-commonjs/tag-picker.js", + "import": "./lib/tag-picker.js", + "require": "./lib-commonjs/tag-picker.js" + }, "./teaching-popover": { "types": "./dist/teaching-popover.d.ts", "node": "./lib-commonjs/teaching-popover.js", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx new file mode 100644 index 0000000000000..86d69bfc506b6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { TagPicker } from './TagPicker'; +import { TagPickerControl } from './TagPickerControl'; +import { TagPickerGroup } from './TagPickerGroup'; +import { TagPickerInput } from './TagPickerInput'; +import { TagPickerList } from './TagPickerList'; +import { TagPickerOption } from './TagPickerOption'; +import { optionClassNames } from '@fluentui/react-combobox'; +import { Tag } from '../Tag'; + +const renderTagPicker = (props: { selectedOptions?: string[]; disabled?: boolean } = {}) => { + const { selectedOptions = [], disabled } = props; + return render( + + + + {selectedOptions.map(option => ( + + {option} + + ))} + + + + + Cat + Ferret + Dog + + , + ); +}; + +describe('TagPicker', () => { + it('renders the input trigger and the options list when open', () => { + const { getByRole, getAllByRole } = renderTagPicker(); + + expect(getByRole('combobox')).toBeInTheDocument(); + expect(getByRole('listbox')).toBeInTheDocument(); + expect(getAllByRole('option')).toHaveLength(3); + }); + + it('sets data-disabled on disabled options', () => { + const { getAllByRole } = renderTagPicker(); + const options = getAllByRole('option'); + + expect(options[0]).not.toHaveAttribute('data-disabled'); + expect(options[1]).toHaveAttribute('data-disabled'); + expect(options[2]).not.toHaveAttribute('data-disabled'); + }); + + it('marks options with the option class so active-descendant arrow navigation can find them', () => { + const { getAllByRole } = renderTagPicker(); + + getAllByRole('option').forEach(option => { + expect(option).toHaveClass(optionClassNames.root); + }); + }); + + it('renders selected options as tags and applies the focusgroup attribute on the group', () => { + const { getByRole } = renderTagPicker({ selectedOptions: ['Dog'] }); + + const group = getByRole('listbox', { name: 'Selected animals' }); + expect(group).toHaveAttribute('focusgroup', 'toolbar inline wrap'); + expect(group).toHaveTextContent('Dog'); + }); + + it('does not render the group when nothing is selected', () => { + const { queryByRole } = renderTagPicker({ selectedOptions: [] }); + + expect(queryByRole('listbox', { name: 'Selected animals' })).not.toBeInTheDocument(); + }); + + it('sets data-disabled on the control when disabled', () => { + const { getByRole } = renderTagPicker({ disabled: true }); + + expect(getByRole('combobox')).toBeDisabled(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.tsx new file mode 100644 index 0000000000000..c2fd2081c83a7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.tsx @@ -0,0 +1,18 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import { useTagPicker } from './useTagPicker'; +import { renderTagPicker } from './renderTagPicker'; +import { useTagPickerContextValues } from './useTagPickerContextValues'; +import type { TagPickerProps } from './TagPicker.types'; + +export const TagPicker: ForwardRefComponent = React.forwardRef((props, _ref) => { + const state = useTagPicker(props); + const contextValues = useTagPickerContextValues(state); + + return renderTagPicker(state, contextValues); +}); + +TagPicker.displayName = 'TagPicker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.types.ts new file mode 100644 index 0000000000000..008035127a0f4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.types.ts @@ -0,0 +1,12 @@ +import type { TagPickerProps as TagPickerPropsBase } from '@fluentui/react-tag-picker'; + +export type { + TagPickerState, + TagPickerContextValues, + TagPickerSlots, + TagPickerSize, + TagPickerOnOpenChangeData, + TagPickerOnOptionSelectData, +} from '@fluentui/react-tag-picker'; + +export type TagPickerProps = Omit; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.tsx new file mode 100644 index 0000000000000..809b4e921dfd8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import { useTagPickerButton } from './useTagPickerButton'; +import { renderTagPickerButton } from './renderTagPickerButton'; +import type { TagPickerButtonProps } from './TagPickerButton.types'; + +/** + * A button trigger for a TagPicker, used as an alternative to `TagPickerInput` when the + * picker does not need free-text filtering. + */ +export const TagPickerButton: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTagPickerButton(props, ref); + + return renderTagPickerButton(state); +}); + +TagPickerButton.displayName = 'TagPickerButton'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.types.ts new file mode 100644 index 0000000000000..0df4a700d6c6c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/TagPickerButton.types.ts @@ -0,0 +1,18 @@ +import type { TagPickerButtonBaseState } from '@fluentui/react-tag-picker'; + +export type { + TagPickerButtonBaseProps as TagPickerButtonProps, + TagPickerButtonSlots, +} from '@fluentui/react-tag-picker'; + +/** + * State used in rendering the headless TagPickerButton. + */ +export type TagPickerButtonState = TagPickerButtonBaseState & { + root: { + /** + * Data attribute set when the button is disabled. + */ + 'data-disabled'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/index.ts new file mode 100644 index 0000000000000..3c912184c0505 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/index.ts @@ -0,0 +1,4 @@ +export { TagPickerButton } from './TagPickerButton'; +export { renderTagPickerButton } from './renderTagPickerButton'; +export { useTagPickerButton } from './useTagPickerButton'; +export type { TagPickerButtonProps, TagPickerButtonSlots, TagPickerButtonState } from './TagPickerButton.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/renderTagPickerButton.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/renderTagPickerButton.tsx new file mode 100644 index 0000000000000..d35f3bdbb31ba --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/renderTagPickerButton.tsx @@ -0,0 +1,15 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TagPickerButtonSlots, TagPickerButtonState } from './TagPickerButton.types'; + +/** + * Render the final JSX of TagPickerButton. + */ +export const renderTagPickerButton = (state: TagPickerButtonState): JSXElement => { + assertSlots(state); + + return ; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/useTagPickerButton.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/useTagPickerButton.ts new file mode 100644 index 0000000000000..9324eb4d5c281 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/useTagPickerButton.ts @@ -0,0 +1,22 @@ +'use client'; + +import type * as React from 'react'; +import { useTagPickerButtonBase_unstable } from '@fluentui/react-tag-picker'; + +import type { TagPickerButtonProps, TagPickerButtonState } from './TagPickerButton.types'; +import { stringifyDataAttribute } from '../../../utils/stringifyDataAttribute'; + +/** + * Returns the state for a headless TagPickerButton. + */ +export const useTagPickerButton = ( + props: TagPickerButtonProps, + ref: React.Ref, +): TagPickerButtonState => { + const state: TagPickerButtonState = useTagPickerButtonBase_unstable(props, ref); + + // eslint-disable-next-line react-hooks/immutability -- decorate base state with data-* attribute + state.root['data-disabled'] = stringifyDataAttribute(state.root.disabled); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.tsx new file mode 100644 index 0000000000000..a163c6886ad4d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import { useTagPickerControl } from './useTagPickerControl'; +import { renderTagPickerControl } from './renderTagPickerControl'; +import type { TagPickerControlProps } from './TagPickerControl.types'; + +/** + * The interactive area of a TagPicker: hosts the selected tags (`TagPickerGroup`) and + * the trigger (`TagPickerInput` or `TagPickerButton`), plus an optional expand icon and + * secondary action. + */ +export const TagPickerControl: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTagPickerControl(props, ref); + + return renderTagPickerControl(state); +}); + +TagPickerControl.displayName = 'TagPickerControl'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.types.ts new file mode 100644 index 0000000000000..d95961b4cd39c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.types.ts @@ -0,0 +1,28 @@ +import type { Slot } from '@fluentui/react-utilities'; +import type { TagPickerControlBaseState } from '@fluentui/react-tag-picker'; + +export type { TagPickerControlProps, TagPickerControlSlots } from '@fluentui/react-tag-picker'; + +/** + * Internal slot rendered by `renderTagPickerControl` to host the expand icon and + * secondary action. + */ +export type TagPickerControlInternalSlots = { + aside?: NonNullable>; +}; + +/** + * State used in rendering the headless TagPickerControl. + */ +export type TagPickerControlState = TagPickerControlBaseState & { + root: { + /** + * Data attribute set when the control is disabled. + */ + 'data-disabled'?: string; + /** + * Data attribute set when the control is in an invalid (error) field state. + */ + 'data-invalid'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/index.ts new file mode 100644 index 0000000000000..d473ddad1cd87 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/index.ts @@ -0,0 +1,9 @@ +export { TagPickerControl } from './TagPickerControl'; +export { renderTagPickerControl } from './renderTagPickerControl'; +export { useTagPickerControl } from './useTagPickerControl'; +export type { + TagPickerControlProps, + TagPickerControlSlots, + TagPickerControlState, + TagPickerControlInternalSlots, +} from './TagPickerControl.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/renderTagPickerControl.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/renderTagPickerControl.tsx new file mode 100644 index 0000000000000..84477fa96e03f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/renderTagPickerControl.tsx @@ -0,0 +1,11 @@ +import type { JSXElement } from '@fluentui/react-utilities'; +import { renderTagPickerControl_unstable } from '@fluentui/react-tag-picker'; + +import type { TagPickerControlState } from './TagPickerControl.types'; + +/** + * Render the final JSX of the headless TagPickerControl. + */ +export const renderTagPickerControl = renderTagPickerControl_unstable as unknown as ( + state: TagPickerControlState, +) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/useTagPickerControl.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/useTagPickerControl.ts new file mode 100644 index 0000000000000..9913a01175e0a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/useTagPickerControl.ts @@ -0,0 +1,27 @@ +'use client'; + +import type * as React from 'react'; +import { useTagPickerControlBase_unstable } from '@fluentui/react-tag-picker'; + +import type { TagPickerControlProps, TagPickerControlState } from './TagPickerControl.types'; +import { stringifyDataAttribute } from '../../../utils/stringifyDataAttribute'; + +/** + * Returns the state for a headless TagPickerControl. + * + * Wraps {@link useTagPickerControlBase_unstable} (which omits `size`/`appearance`) and + * exposes the disabled/invalid state as `data-*` attributes for styling hooks. + */ +export const useTagPickerControl = ( + props: TagPickerControlProps, + ref: React.Ref, +): TagPickerControlState => { + const state: TagPickerControlState = useTagPickerControlBase_unstable(props, ref); + + /* eslint-disable react-hooks/immutability -- decorate base state with data-* attributes */ + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + state.root['data-invalid'] = stringifyDataAttribute(state.invalid); + /* eslint-enable react-hooks/immutability */ + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.tsx new file mode 100644 index 0000000000000..b9adbacc75f2c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.tsx @@ -0,0 +1,22 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import { useTagPickerGroup } from './useTagPickerGroup'; +import { renderTagPickerGroup } from './renderTagPickerGroup'; +import { useTagGroupContextValues } from '../../TagGroup/useTagGroupContextValues'; +import type { TagPickerGroupProps } from './TagPickerGroup.types'; + +/** + * Displays the selected options of a TagPicker as a group of dismissible tags. Uses the + * native `focusgroup` attribute for arrow-key navigation across the tags. + */ +export const TagPickerGroup: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTagPickerGroup(props, ref); + const contextValues = useTagGroupContextValues(state); + + return renderTagPickerGroup(state, contextValues); +}); + +TagPickerGroup.displayName = 'TagPickerGroup'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts new file mode 100644 index 0000000000000..229aa3ddfefb0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts @@ -0,0 +1,31 @@ +import type { ComponentProps } from '@fluentui/react-utilities'; +import type { TagGroupBaseState } from '@fluentui/react-tags'; +import type { TagPickerGroupSlots } from '@fluentui/react-tag-picker'; + +export type { TagPickerGroupSlots }; + +/** + * TagPickerGroup Props + */ +export type TagPickerGroupProps = ComponentProps; + +/** + * State used in rendering the headless TagPickerGroup. + */ +export type TagPickerGroupState = TagGroupBaseState & { + /** + * Whether any options are currently selected. When `false`, the group renders nothing. + */ + hasSelectedOptions: boolean; + root: { + /** + * Native WICG `focusgroup` attribute for arrow-key navigation across the selected tags. + * Replaces the Tabster `useArrowNavigationGroup` used by the styled TagPickerGroup. + */ + focusgroup?: string; + /** + * Data attribute set when the group is disabled. + */ + 'data-disabled'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/index.ts new file mode 100644 index 0000000000000..20b33bf0ff20a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/index.ts @@ -0,0 +1,4 @@ +export { TagPickerGroup } from './TagPickerGroup'; +export { renderTagPickerGroup } from './renderTagPickerGroup'; +export { useTagPickerGroup } from './useTagPickerGroup'; +export type { TagPickerGroupProps, TagPickerGroupSlots, TagPickerGroupState } from './TagPickerGroup.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/renderTagPickerGroup.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/renderTagPickerGroup.tsx new file mode 100644 index 0000000000000..fabfb0b75abad --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/renderTagPickerGroup.tsx @@ -0,0 +1,13 @@ +import type { JSXElement } from '@fluentui/react-utilities'; +import { renderTagPickerGroup_unstable } from '@fluentui/react-tag-picker'; +import type { TagGroupContextValues } from '@fluentui/react-tags'; + +import type { TagPickerGroupState } from './TagPickerGroup.types'; + +/** + * Render the final JSX of the headless TagPickerGroup. + */ +export const renderTagPickerGroup = renderTagPickerGroup_unstable as unknown as ( + state: TagPickerGroupState, + contextValues: TagGroupContextValues, +) => JSXElement | null; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/useTagPickerGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/useTagPickerGroup.ts new file mode 100644 index 0000000000000..a56f7215b1cd1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/useTagPickerGroup.ts @@ -0,0 +1,57 @@ +'use client'; + +import type * as React from 'react'; +import { useTagGroupBase_unstable } from '@fluentui/react-tags'; +import { useTagPickerContext_unstable } from '@fluentui/react-tag-picker'; +import { isHTMLElement, useEventCallback, useMergedRefs } from '@fluentui/react-utilities'; +import { ArrowRight } from '@fluentui/keyboard-keys'; + +import type { TagPickerGroupProps, TagPickerGroupState } from './TagPickerGroup.types'; +import { stringifyDataAttribute } from '../../../utils/stringifyDataAttribute'; + +/** + * Returns the state for a headless TagPickerGroup. + */ +export const useTagPickerGroup = (props: TagPickerGroupProps, ref: React.Ref): TagPickerGroupState => { + const hasSelectedOptions = useTagPickerContext_unstable(ctx => ctx.selectedOptions.length > 0); + const hasOneSelectedOption = useTagPickerContext_unstable(ctx => ctx.selectedOptions.length === 1); + const triggerRef = useTagPickerContext_unstable(ctx => ctx.triggerRef); + const tagPickerGroupRef = useTagPickerContext_unstable(ctx => ctx.tagPickerGroupRef); + const selectOption = useTagPickerContext_unstable(ctx => ctx.selectOption); + const disabled = useTagPickerContext_unstable(ctx => ctx.disabled); + + const state: Omit = useTagGroupBase_unstable( + { + role: 'listbox', + disabled, + ...props, + dismissible: true, + onKeyDown: useEventCallback((event: React.KeyboardEvent) => { + props.onKeyDown?.(event); + if (isHTMLElement(event.target) && event.key === ArrowRight) { + triggerRef.current?.focus(); + } + }), + onDismiss: useEventCallback((event, data) => { + selectOption(event as React.MouseEvent | React.KeyboardEvent, { + value: data.value, + // These values no longer exist because the option has unregistered itself + // for the purposes of selection - these values aren't actually used + id: 'ERROR_DO_NOT_USE', + text: 'ERROR_DO_NOT_USE', + }); + if (hasOneSelectedOption && !event.isDefaultPrevented()) { + triggerRef.current?.focus(); + } + }), + }, + useMergedRefs(ref, tagPickerGroupRef), + ); + + /* eslint-disable react-hooks/immutability -- swap Tabster arrow-nav for the native focusgroup + surface disabled as data-* */ + state.root.focusgroup = 'toolbar inline wrap'; + state.root['data-disabled'] = stringifyDataAttribute(disabled); + /* eslint-enable react-hooks/immutability */ + + return { ...state, hasSelectedOptions }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.tsx new file mode 100644 index 0000000000000..54370ef51f830 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import { useTagPickerInput } from './useTagPickerInput'; +import { renderTagPickerInput } from './renderTagPickerInput'; +import type { TagPickerInputProps } from './TagPickerInput.types'; + +/** + * The text input trigger of a TagPicker, used to filter options and open the popover. + */ +export const TagPickerInput: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTagPickerInput(props, ref); + + return renderTagPickerInput(state); +}); + +TagPickerInput.displayName = 'TagPickerInput'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.types.ts new file mode 100644 index 0000000000000..e38c86533d8cf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/TagPickerInput.types.ts @@ -0,0 +1,15 @@ +import type { TagPickerInputBaseState } from '@fluentui/react-tag-picker'; + +export type { TagPickerInputBaseProps as TagPickerInputProps, TagPickerInputSlots } from '@fluentui/react-tag-picker'; + +/** + * State used in rendering the headless TagPickerInput. + */ +export type TagPickerInputState = TagPickerInputBaseState & { + root: { + /** + * Data attribute set when the input is disabled. + */ + 'data-disabled'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/index.ts new file mode 100644 index 0000000000000..f89793d1a1ac1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/index.ts @@ -0,0 +1,4 @@ +export { TagPickerInput } from './TagPickerInput'; +export { renderTagPickerInput } from './renderTagPickerInput'; +export { useTagPickerInput } from './useTagPickerInput'; +export type { TagPickerInputProps, TagPickerInputSlots, TagPickerInputState } from './TagPickerInput.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/renderTagPickerInput.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/renderTagPickerInput.tsx new file mode 100644 index 0000000000000..c326074380a4d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/renderTagPickerInput.tsx @@ -0,0 +1,11 @@ +import type { JSXElement } from '@fluentui/react-utilities'; +import { renderTagPickerInput_unstable } from '@fluentui/react-tag-picker'; + +import type { TagPickerInputState } from './TagPickerInput.types'; + +/** + * Render the final JSX of the headless TagPickerInput. + */ +export const renderTagPickerInput = renderTagPickerInput_unstable as unknown as ( + state: TagPickerInputState, +) => JSXElement; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/useTagPickerInput.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/useTagPickerInput.ts new file mode 100644 index 0000000000000..59fec35f0170b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/useTagPickerInput.ts @@ -0,0 +1,48 @@ +'use client'; + +import type * as React from 'react'; +import { useEventCallback } from '@fluentui/react-utilities'; +import { ArrowLeft, Backspace } from '@fluentui/keyboard-keys'; +import { useTagPickerContext_unstable, useTagPickerInputBase_unstable } from '@fluentui/react-tag-picker'; + +import type { TagPickerInputProps, TagPickerInputState } from './TagPickerInput.types'; +import { stringifyDataAttribute } from '../../../utils/stringifyDataAttribute'; + +/** + * Returns the state for a headless TagPickerInput. + */ +export const useTagPickerInput = ( + props: TagPickerInputProps, + ref: React.Ref, +): TagPickerInputState => { + const tagPickerGroupRef = useTagPickerContext_unstable(ctx => ctx.tagPickerGroupRef); + + const onKeyDown = useEventCallback((event: React.KeyboardEvent) => { + props.onKeyDown?.(event); + if ( + (event.key === ArrowLeft || event.key === Backspace) && + event.currentTarget.selectionStart === 0 && + event.currentTarget.selectionEnd === 0 && + tagPickerGroupRef.current + ) { + focusLastTag(tagPickerGroupRef.current); + } + }); + + const state: TagPickerInputState = useTagPickerInputBase_unstable({ ...props, onKeyDown }, ref); + + // eslint-disable-next-line react-hooks/immutability -- decorate base state with data-* attribute + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + + return state; +}; + +/** + * Moves focus to the last focusable element within the tag group. The focusgroup polyfill + * manages roving `tabindex` on the tags, so the items carry `tabindex="-1"`; they remain + * programmatically focusable, which is why we don't filter on `tabindex`. + */ +function focusLastTag(container: HTMLElement): void { + const focusable = container.querySelectorAll('button, [tabindex]'); + focusable[focusable.length - 1]?.focus(); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/TagPickerList.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/TagPickerList.tsx new file mode 100644 index 0000000000000..0921137c0022f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/TagPickerList.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import { useTagPickerList } from './useTagPickerList'; +import { renderTagPickerList } from './renderTagPickerList'; +import type { TagPickerListProps } from './TagPickerList.types'; + +/** + * The popover list of selectable options for a TagPicker. Holds `Option` and `OptionGroup` + * children. + */ +export const TagPickerList: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTagPickerList(props, ref); + + return renderTagPickerList(state); +}); + +TagPickerList.displayName = 'TagPickerList'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/TagPickerList.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/TagPickerList.types.ts new file mode 100644 index 0000000000000..39436338e3571 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/TagPickerList.types.ts @@ -0,0 +1,22 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { Listbox } from '../../Dropdown/Listbox'; + +export type TagPickerListSlots = { + root: Slot; +}; + +/** + * TagPickerList Props + */ +export type TagPickerListProps = ComponentProps; + +/** + * State used in rendering the headless TagPickerList. + */ +export type TagPickerListState = ComponentState & { + /** + * Whether the popover is currently open. Visibility is governed by the TagPicker root, which only + * renders the popover while open or focused. + */ + open: boolean; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/index.ts new file mode 100644 index 0000000000000..cfb2a223430e7 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/index.ts @@ -0,0 +1,4 @@ +export { TagPickerList } from './TagPickerList'; +export { renderTagPickerList } from './renderTagPickerList'; +export { useTagPickerList } from './useTagPickerList'; +export type { TagPickerListProps, TagPickerListSlots, TagPickerListState } from './TagPickerList.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/renderTagPickerList.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/renderTagPickerList.tsx new file mode 100644 index 0000000000000..9da71642fb7fe --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/renderTagPickerList.tsx @@ -0,0 +1,15 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TagPickerListSlots, TagPickerListState } from './TagPickerList.types'; + +/** + * Render the final JSX of TagPickerList. + */ +export const renderTagPickerList = (state: TagPickerListState): JSXElement => { + assertSlots(state); + + return ; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/useTagPickerList.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/useTagPickerList.ts new file mode 100644 index 0000000000000..fe77046924045 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/useTagPickerList.ts @@ -0,0 +1,34 @@ +'use client'; + +import type * as React from 'react'; +import { slot, useMergedRefs } from '@fluentui/react-utilities'; +import { useTagPickerContext_unstable } from '@fluentui/react-tag-picker'; + +import { Listbox } from '../../Dropdown/Listbox'; +import { useListboxSlot } from '../../Dropdown/useListboxSlot'; +import type { TagPickerListProps, TagPickerListState } from './TagPickerList.types'; + +/** + * Returns the state for a headless TagPickerList. + */ +export const useTagPickerList = (props: TagPickerListProps, ref: React.Ref): TagPickerListState => { + const triggerRef = useTagPickerContext_unstable(ctx => ctx.triggerRef) as React.RefObject< + HTMLInputElement | HTMLButtonElement | null + >; + const popoverRef = useTagPickerContext_unstable(ctx => ctx.popoverRef); + const popoverId = useTagPickerContext_unstable(ctx => ctx.popoverId); + const open = useTagPickerContext_unstable(ctx => ctx.open); + const setOpen = useTagPickerContext_unstable(ctx => ctx.setOpen); + + const listboxSlot = useListboxSlot(props, useMergedRefs(popoverRef, ref), { + state: { multiselect: true, open, setOpen }, + triggerRef, + defaultProps: { id: popoverId }, + }); + + return { + open, + components: { root: Listbox }, + root: slot.always({ ...listboxSlot, role: 'listbox' }, { elementType: Listbox }), + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.tsx new file mode 100644 index 0000000000000..14f7dd11205ae --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import { useTagPickerOption } from './useTagPickerOption'; +import { renderTagPickerOption } from './renderTagPickerOption'; +import type { TagPickerOptionProps } from './TagPickerOption.types'; + +/** + * An option within a TagPickerList. Behaves like the headless `Option`, but is marked so the + * TagPicker's active-descendant controller can navigate it with the arrow keys. + */ +export const TagPickerOption: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTagPickerOption(props, ref); + + return renderTagPickerOption(state); +}); + +TagPickerOption.displayName = 'TagPickerOption'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.types.ts new file mode 100644 index 0000000000000..5b3ec20acba42 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.types.ts @@ -0,0 +1,33 @@ +import type { Slot } from '@fluentui/react-utilities'; +import type { OptionProps, OptionSlots, OptionState } from '../../Dropdown/Option'; + +export type TagPickerOptionSlots = OptionSlots & { + /** + * Media rendered before the option's text content (e.g. an avatar or icon). + */ + media?: Slot<'span'>; + /** + * Secondary text rendered after the option's text content. + */ + secondaryContent?: Slot<'span'>; +}; + +/** + * TagPickerOption Props + */ +export type TagPickerOptionProps = OptionProps & { + media?: Slot<'span'>; + secondaryContent?: Slot<'span'>; +}; + +/** + * State used in rendering the headless TagPickerOption. + */ +export type TagPickerOptionState = OptionState & { + components: OptionState['components'] & { + media: 'span'; + secondaryContent: 'span'; + }; + media?: Slot<'span'>; + secondaryContent?: Slot<'span'>; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/index.ts new file mode 100644 index 0000000000000..e96bd1af2e251 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/index.ts @@ -0,0 +1,4 @@ +export { TagPickerOption } from './TagPickerOption'; +export { renderTagPickerOption } from './renderTagPickerOption'; +export { useTagPickerOption } from './useTagPickerOption'; +export type { TagPickerOptionProps, TagPickerOptionSlots, TagPickerOptionState } from './TagPickerOption.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/renderTagPickerOption.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/renderTagPickerOption.tsx new file mode 100644 index 0000000000000..b6bf006acbf62 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/renderTagPickerOption.tsx @@ -0,0 +1,21 @@ +/** @jsxRuntime automatic */ +/** @jsxImportSource @fluentui/react-jsx-runtime */ + +import { assertSlots } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import type { TagPickerOptionSlots, TagPickerOptionState } from './TagPickerOption.types'; + +/** + * Render the final JSX of TagPickerOption. + */ +export const renderTagPickerOption = (state: TagPickerOptionState): JSXElement => { + assertSlots(state); + + return ( + + {state.media && } + {state.root.children} + {state.secondaryContent && } + + ); +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/useTagPickerOption.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/useTagPickerOption.ts new file mode 100644 index 0000000000000..f1bb8fe704dd0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/useTagPickerOption.ts @@ -0,0 +1,47 @@ +'use client'; + +import type * as React from 'react'; +import { slot } from '@fluentui/react-utilities'; +import { optionClassNames } from '@fluentui/react-combobox'; + +import { useOption } from '../../Dropdown/Option'; +import type { TagPickerOptionProps, TagPickerOptionState } from './TagPickerOption.types'; + +/** + * Returns the state for a headless TagPickerOption. + * + * Wraps the headless {@link useOption} and: + * - tags the root with `optionClassNames.root` so the TagPicker root (reusing + * `useTagPickerBase_unstable`) can navigate the options with the arrow keys, which it does by + * matching that class; and + * - adds optional `media` and `secondaryContent` slots, mirroring the styled `TagPickerOption`. + */ +export const useTagPickerOption = (props: TagPickerOptionProps, ref: React.Ref): TagPickerOptionState => { + // Keep media/secondaryContent off the underlying option props so they aren't spread onto the root. + const { media, secondaryContent, ...optionProps } = props; + const optionState = useOption(optionProps, ref); + + /* eslint-disable react-hooks/immutability -- decorate the base option state */ + // Mark the option so the active-descendant controller (matching by class) can navigate it. + optionState.root.className = optionState.root.className + ? `${optionClassNames.root} ${optionState.root.className}` + : optionClassNames.root; + + // Force the listbox option role: the base option uses role="menuitemcheckbox" in multiselect mode, + // but a TagPickerList is a listbox, so its options must be role="option" (mirrors v9's TagPickerOption). + optionState.root.role = 'option'; + optionState.root['aria-checked'] = props['aria-checked']; + /* eslint-enable react-hooks/immutability */ + + return { + ...optionState, + components: { + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...optionState.components, + media: 'span', + secondaryContent: 'span', + }, + media: slot.optional(media, { elementType: 'span' }), + secondaryContent: slot.optional(secondaryContent, { elementType: 'span' }), + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/index.ts new file mode 100644 index 0000000000000..723e0db81613b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/index.ts @@ -0,0 +1,47 @@ +export { TagPicker } from './TagPicker'; +export { renderTagPicker } from './renderTagPicker'; +export { useTagPicker } from './useTagPicker'; +export { useTagPickerContextValues } from './useTagPickerContextValues'; +export type { + TagPickerProps, + TagPickerSlots, + TagPickerState, + TagPickerContextValues, + TagPickerSize, + TagPickerOnOpenChangeData, + TagPickerOnOptionSelectData, +} from './TagPicker.types'; + +export { TagPickerControl, renderTagPickerControl, useTagPickerControl } from './TagPickerControl'; +export type { + TagPickerControlProps, + TagPickerControlSlots, + TagPickerControlState, + TagPickerControlInternalSlots, +} from './TagPickerControl'; + +export { TagPickerInput, renderTagPickerInput, useTagPickerInput } from './TagPickerInput'; +export type { TagPickerInputProps, TagPickerInputSlots, TagPickerInputState } from './TagPickerInput'; + +export { TagPickerButton, renderTagPickerButton, useTagPickerButton } from './TagPickerButton'; +export type { TagPickerButtonProps, TagPickerButtonSlots, TagPickerButtonState } from './TagPickerButton'; + +export { TagPickerGroup, renderTagPickerGroup, useTagPickerGroup } from './TagPickerGroup'; +export type { TagPickerGroupProps, TagPickerGroupSlots, TagPickerGroupState } from './TagPickerGroup'; + +export { TagPickerList, renderTagPickerList, useTagPickerList } from './TagPickerList'; +export type { TagPickerListProps, TagPickerListSlots, TagPickerListState } from './TagPickerList'; + +export { TagPickerOption, renderTagPickerOption, useTagPickerOption } from './TagPickerOption'; +export type { TagPickerOptionProps, TagPickerOptionSlots, TagPickerOptionState } from './TagPickerOption'; + +export { + OptionGroup as TagPickerOptionGroup, + renderOptionGroup as renderTagPickerOptionGroup, + useOptionGroup as useTagPickerOptionGroup, +} from '../Dropdown/OptionGroup'; +export type { + OptionGroupSlots as TagPickerOptionGroupSlots, + OptionGroupProps as TagPickerOptionGroupProps, + OptionGroupState as TagPickerOptionGroupState, +} from '../Dropdown/OptionGroup'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/renderTagPicker.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/renderTagPicker.tsx new file mode 100644 index 0000000000000..93bdfe09d6e74 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/renderTagPicker.tsx @@ -0,0 +1 @@ +export { renderTagPicker_unstable as renderTagPicker } from '@fluentui/react-tag-picker'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPicker.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPicker.ts new file mode 100644 index 0000000000000..aa7cb803d5a49 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPicker.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useMergedRefs } from '@fluentui/react-utilities'; +import { useTagPickerBase_unstable } from '@fluentui/react-tag-picker'; + +import { resolvePositioningShorthand, usePositioning } from '../../positioning'; +import type { PositioningShorthandValue } from '../../positioning'; +import type { TagPickerProps, TagPickerState } from './TagPicker.types'; + +const fallbackPositions: PositioningShorthandValue[] = ['above', 'after', 'after-top', 'before', 'before-top']; + +/** + * Returns the state for a headless TagPicker. + */ +export const useTagPicker = (props: TagPickerProps): TagPickerState => { + const { targetRef, containerRef } = usePositioning({ + position: 'below', + align: 'start', + offset: { crossAxis: 0, mainAxis: 2 }, + fallbackPositions, + matchTargetSize: 'width', + ...resolvePositioningShorthand(props.positioning), + }); + + const baseState = useTagPickerBase_unstable({ ...props, inline: true }); + + return { + ...baseState, + targetRef: targetRef as unknown as TagPickerState['targetRef'], + popoverRef: useMergedRefs(baseState.popoverRef, containerRef) as unknown as TagPickerState['popoverRef'], + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerContextValues.ts new file mode 100644 index 0000000000000..f4d0b60a20376 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerContextValues.ts @@ -0,0 +1,78 @@ +'use client'; + +import * as React from 'react'; +import type { TagPickerContextValues, TagPickerState } from './TagPicker.types'; + +const noop = () => { + /** noop */ +}; + +/** + * Assembles the context values consumed by the TagPicker subcomponents. + * + * Mirrors the styled package's `useTagPickerContextValues`, which is not part of the + * public API. Pure context wiring — no styles. + */ +export function useTagPickerContextValues(state: TagPickerState): TagPickerContextValues { + const { + onOptionClick, + registerOption, + selectedOptions, + selectOption, + value, + triggerRef, + secondaryActionRef, + tagPickerGroupRef, + targetRef, + size, + setValue, + setOpen, + setHasFocus, + popoverRef, + appearance, + clearSelection, + getOptionById, + getOptionsMatchingValue, + open, + popoverId, + disabled, + noPopover, + } = state; + return { + activeDescendant: React.useMemo( + () => ({ controller: state.activeDescendantController }), + [state.activeDescendantController], + ), + listbox: { + onOptionClick, + registerOption, + getOptionById, + getOptionsMatchingValue, + selectedOptions, + selectOption, + focusVisible: false, + setActiveOption: noop, + }, + picker: { + value, + triggerRef, + targetRef, + secondaryActionRef, + tagPickerGroupRef, + size, + setValue, + setOpen, + setHasFocus, + selectOption, + popoverRef, + selectedOptions, + appearance, + clearSelection, + getOptionById, + open, + popoverId, + disabled, + noPopover, + }, + }; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/tag-picker.ts b/packages/react-components/react-headless-components-preview/library/src/tag-picker.ts new file mode 100644 index 0000000000000..6d21c280c5b93 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/tag-picker.ts @@ -0,0 +1,65 @@ +export { + TagPicker, + renderTagPicker, + useTagPicker, + useTagPickerContextValues, + TagPickerControl, + renderTagPickerControl, + useTagPickerControl, + TagPickerInput, + renderTagPickerInput, + useTagPickerInput, + TagPickerButton, + renderTagPickerButton, + useTagPickerButton, + TagPickerGroup, + renderTagPickerGroup, + useTagPickerGroup, + TagPickerList, + renderTagPickerList, + useTagPickerList, + TagPickerOption, + renderTagPickerOption, + useTagPickerOption, + TagPickerOptionGroup, + renderTagPickerOptionGroup, + useTagPickerOptionGroup, +} from './components/TagPicker'; +export type { + TagPickerProps, + TagPickerSlots, + TagPickerState, + TagPickerContextValues, + TagPickerSize, + TagPickerOnOpenChangeData, + TagPickerOnOptionSelectData, + TagPickerControlProps, + TagPickerControlSlots, + TagPickerControlState, + TagPickerControlInternalSlots, + TagPickerInputProps, + TagPickerInputSlots, + TagPickerInputState, + TagPickerButtonProps, + TagPickerButtonSlots, + TagPickerButtonState, + TagPickerGroupProps, + TagPickerGroupSlots, + TagPickerGroupState, + TagPickerListProps, + TagPickerListSlots, + TagPickerListState, + TagPickerOptionProps, + TagPickerOptionSlots, + TagPickerOptionState, + TagPickerOptionGroupProps, + TagPickerOptionGroupSlots, + TagPickerOptionGroupState, +} from './components/TagPicker'; + +// Re-exported from the styled package so consumers can filter options by the input value. +export { useTagPickerFilter } from '@fluentui/react-tag-picker'; + +// Re-exported so consumers can read TagPicker state when composing custom triggers/inputs. +export { useTagPickerContext_unstable } from '@fluentui/react-tag-picker'; +export type { TagPickerContextValue } from '@fluentui/react-tag-picker'; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerDefault.stories.tsx new file mode 100644 index 0000000000000..9e508ab17bd80 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerDefault.stories.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerList, + TagPickerOption, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import type { TagPickerProps } from '@fluentui/react-headless-components-preview/tag-picker'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tag-picker.module.css'; +import { Media } from './utils'; + +const options = [ + 'John Doe', + 'Jane Doe', + 'Max Mustermann', + 'Erika Mustermann', + 'Pierre Dupont', + 'Amelie Dupont', + 'Mario Rossi', + 'Maria Rossi', +]; + +export const Default = (): React.ReactNode => { + const [selectedOptions, setSelectedOptions] = React.useState([]); + + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_e, data) => { + setSelectedOptions(data.selectedOptions); + }; + + const availableOptions = options.filter(option => !selectedOptions.includes(option)); + + return ( +
+ + + + + {selectedOptions.map(option => ( + } + dismissIcon={{ + className: styles.dismissIcon, + 'aria-label': 'remove', + children: , + }} + > + {option} + + ))} + + + + + {availableOptions.length > 0 ? ( + availableOptions.map(option => ( + } + secondaryContent={{ className: styles.secondaryContent, children: 'Microsoft FTE' }} + > + {option} + + )) + ) : ( + + No options available + + )} + + +
+ ); +}; + +Default.parameters = { + docs: { + description: { + story: + 'A multiselect TagPicker. Selected options render as dismissible tags in the `TagPickerGroup`, ' + + 'which uses the native `focusgroup` attribute for arrow-key navigation across the tags. ' + + 'Press ArrowLeft or Backspace at the start of an empty input to move focus into the tags.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerDescription.md b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerDescription.md new file mode 100644 index 0000000000000..1281efe2afd24 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerDescription.md @@ -0,0 +1,27 @@ +The headless **TagPicker** is a combobox-like control for selecting multiple options, each +rendered as a dismissible tag. It composes: + +- `TagPicker` — the root; owns combobox state and provides context. No DOM element, no styles. +- `TagPickerControl` — the interactive area that holds the tags and the trigger. +- `TagPickerGroup` — the selected tags. Uses the native WICG **`focusgroup`** attribute for + arrow-key navigation across the tags, replacing Tabster's `useArrowNavigationGroup`. +- `TagPickerInput` / `TagPickerButton` — the trigger. +- `TagPickerList` — the popover list of `Option` / `OptionGroup` children. + +It wraps the base hooks exported by `@fluentui/react-tag-picker` (`useTagPickerBase_unstable`, +`useTagPickerControlBase_unstable`, `useTagPickerInputBase_unstable`, +`useTagPickerButtonBase_unstable`) and ships **no styles** — bring your own via `className` and the +`data-*` attributes the hooks expose. + +### Positioning + +The base hook omits floating-ui positioning. Pass the `inline` prop (as these stories do) to render +the popover in DOM order and position it with CSS, or compose your own positioning around the +`TagPickerList`. + +### Keyboard navigation requires a focusgroup polyfill + +Arrow-key navigation across the tags relies on the native `focusgroup` attribute, which is not yet +shipped in browsers. These stories load [`@microsoft/focusgroup-polyfill`](https://www.npmjs.com/package/@microsoft/focusgroup-polyfill) +in `.storybook/preview.js`; consumers must load an equivalent polyfill until `focusgroup` is +natively supported. diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerFiltering.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerFiltering.stories.tsx new file mode 100644 index 0000000000000..9099467937080 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerFiltering.stories.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerList, + TagPickerOption, + useTagPickerFilter, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import type { TagPickerProps } from '@fluentui/react-headless-components-preview/tag-picker'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tag-picker.module.css'; +import { Media } from './utils'; + +const options = [ + 'John Doe', + 'Jane Doe', + 'Max Mustermann', + 'Erika Mustermann', + 'Pierre Dupont', + 'Amelie Dupont', + 'Mario Rossi', + 'Maria Rossi', +]; + +export const Filtering = (): React.ReactNode => { + const [query, setQuery] = React.useState(''); + const [selectedOptions, setSelectedOptions] = React.useState([]); + // disable auto focus when no query is present (e.g. opened by keyboard) + const disableAutoFocus = query.length === 0; + + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_e, data) => { + if (data.value === 'no-matches') { + return; + } + setSelectedOptions(data.selectedOptions); + setQuery(''); + }; + + const children = useTagPickerFilter({ + query, + options, + noOptionsElement: ( + + We couldn't find any matches + + ), + renderOption: option => ( + } + secondaryContent={{ className: styles.secondaryContent, children: 'Microsoft FTE' }} + > + {option} + + ), + filter: option => !selectedOptions.includes(option) && option.toLowerCase().includes(query.toLowerCase()), + }); + + return ( +
+ + + + + {selectedOptions.map(option => ( + } + dismissIcon={{ + className: styles.dismissIcon, + 'aria-label': 'remove', + children: , + }} + > + {option} + + ))} + + setQuery(e.target.value)} + /> + + {children} + +
+ ); +}; + +Filtering.parameters = { + docs: { + description: { + story: + 'The `useTagPickerFilter` hook filters options by the typed query. Pass a custom `renderOption` so it ' + + 'renders the headless `Option` (its default renders the styled `TagPickerOption`).', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerGrouped.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerGrouped.stories.tsx new file mode 100644 index 0000000000000..53efd8d3b77ff --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerGrouped.stories.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerList, + TagPickerOption, + TagPickerOptionGroup, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import type { TagPickerProps } from '@fluentui/react-headless-components-preview/tag-picker'; + +import styles from './tag-picker.module.css'; +import { PersonOption, SelectedTag } from './utils'; + +const managers = ['John Doe', 'Jane Doe', 'Max Mustermann', 'Erika Mustermann']; +const devs = ['Pierre Dupont', 'Amelie Dupont', 'Mario Rossi', 'Maria Rossi']; + +export const Grouped = (): React.ReactNode => { + const [selectedOptions, setSelectedOptions] = React.useState([]); + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_e, data) => { + if (data.value === 'no-options') { + return; + } + setSelectedOptions(data.selectedOptions); + }; + const unselectedManagers = managers.filter(option => !selectedOptions.includes(option)); + const unselectedDevs = devs.filter(option => !selectedOptions.includes(option)); + + return ( +
+ + + + + {selectedOptions.map(option => ( + + ))} + + + + + {unselectedManagers.length === 0 && unselectedDevs.length === 0 && ( + + No options available + + )} + {unselectedManagers.length > 0 && ( + + {unselectedManagers.map(option => ( + + ))} + + )} + {unselectedDevs.length > 0 && ( + + {unselectedDevs.map(option => ( + + ))} + + )} + + +
+ ); +}; + +Grouped.parameters = { + docs: { + description: { + story: 'Options can be organized into labeled sections with `TagPickerOptionGroup`.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerNoPopover.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerNoPopover.stories.tsx new file mode 100644 index 0000000000000..02f232d08ef2f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerNoPopover.stories.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import type { TagPickerProps } from '@fluentui/react-headless-components-preview/tag-picker'; + +import styles from './tag-picker.module.css'; +import { SelectedTag } from './utils'; + +export const NoPopover = (): React.ReactNode => { + const [selectedOptions, setSelectedOptions] = React.useState([]); + const [inputValue, setInputValue] = React.useState(''); + + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_e, data) => { + setSelectedOptions(data.selectedOptions); + }; + const handleChange = (event: React.ChangeEvent) => { + setInputValue(event.currentTarget.value); + }; + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && inputValue) { + setInputValue(''); + setSelectedOptions(curr => (curr.includes(inputValue) ? curr : [...curr, inputValue])); + } + }; + + return ( +
+ + + + + {selectedOptions.map(option => ( + + ))} + + + + +
+ ); +}; + +NoPopover.parameters = { + docs: { + description: { + story: + 'Set `noPopover` to use the TagPicker without an options list — useful for free-text tag entry. ' + + 'Control the `TagPickerInput` value and add a tag on Enter.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerSecondaryAction.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerSecondaryAction.stories.tsx new file mode 100644 index 0000000000000..018dfe6106235 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerSecondaryAction.stories.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerList, + TagPickerOption, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import type { TagPickerProps } from '@fluentui/react-headless-components-preview/tag-picker'; + +import styles from './tag-picker.module.css'; +import { PersonOption, SelectedTag } from './utils'; + +const options = [ + 'John Doe', + 'Jane Doe', + 'Max Mustermann', + 'Erika Mustermann', + 'Pierre Dupont', + 'Amelie Dupont', + 'Mario Rossi', + 'Maria Rossi', +]; + +export const SecondaryAction = (): React.ReactNode => { + const [selectedOptions, setSelectedOptions] = React.useState([options[0]]); + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_e, data) => { + if (data.value === 'no-options') { + return; + } + setSelectedOptions(data.selectedOptions); + }; + const availableOptions = options.filter(option => !selectedOptions.includes(option)); + + return ( +
+ + + setSelectedOptions([])}> + All Clear + + ), + }} + > + + {selectedOptions.map(option => ( + + ))} + + + + + {availableOptions.length > 0 ? ( + availableOptions.map(option => ) + ) : ( + + No options available + + )} + + +
+ ); +}; + +SecondaryAction.parameters = { + docs: { + description: { + story: + '`TagPickerControl` provides a `secondaryAction` slot for extra functionality (here, an "All Clear" ' + + 'button). It is rendered alongside the expand icon in the control aside.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerSingleSelect.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerSingleSelect.stories.tsx new file mode 100644 index 0000000000000..bf5471bb3c356 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerSingleSelect.stories.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerList, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import type { TagPickerProps } from '@fluentui/react-headless-components-preview/tag-picker'; + +import styles from './tag-picker.module.css'; +import { PersonOption, SelectedTag } from './utils'; + +const options = [ + 'John Doe', + 'Jane Doe', + 'Max Mustermann', + 'Erika Mustermann', + 'Pierre Dupont', + 'Amelie Dupont', + 'Mario Rossi', + 'Maria Rossi', +]; + +export const SingleSelect = (): React.ReactNode => { + const [selectedOption, setSelectedOption] = React.useState(); + const selectedOptions = React.useMemo(() => (selectedOption ? [selectedOption] : []), [selectedOption]); + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_e, data) => { + setSelectedOption(selectedOption === data.value ? undefined : data.value); + }; + + return ( +
+ + + + {selectedOption && ( + + + + )} + + + + {options + .filter(option => selectedOption !== option) + .map(option => ( + + ))} + + +
+ ); +}; + +SingleSelect.parameters = { + docs: { + description: { + story: + 'The TagPicker is multiselect by default. For single selection, manage the selected-option state ' + + 'yourself and pass at most one selected option.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerTruncatedText.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerTruncatedText.stories.tsx new file mode 100644 index 0000000000000..2b1a7f6918499 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerTruncatedText.stories.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerList, + TagPickerOption, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import type { TagPickerProps } from '@fluentui/react-headless-components-preview/tag-picker'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tag-picker.module.css'; +import { Media } from './utils'; + +type Option = { value: string; fixedWidth?: boolean }; + +const options: Option[] = [ + { value: 'John Doe' }, + { value: 'Jane Doe' }, + { value: 'Max Mustermann' }, + { value: 'Erika Mustermann' }, + { value: 'This tag has text truncation based on a fixed width of 50px', fixedWidth: true }, + { + value: + 'This tag has text truncation based on its container width. This is a long text that will be truncated when it reaches the end of the container.', + }, +]; + +export const TruncatedText = (): React.ReactNode => { + const [selectedOptions, setSelectedOptions] = React.useState(options); + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_e, data) => { + if (data.value === 'no-options') { + return; + } + setSelectedOptions(data.selectedOptions.map(value => options.find(o => o.value === value)!)); + }; + const availableOptions = options.filter(option => !selectedOptions.includes(option)); + + return ( +
+ + option.value)}> + + + {selectedOptions.map(option => ( + } + primaryText={{ + className: option.fixedWidth ? `${styles.truncate} ${styles.truncateFixed}` : styles.truncate, + }} + dismissIcon={{ + className: styles.dismissIcon, + 'aria-label': 'remove', + children: , + }} + > + {option.value} + + ))} + + + + + {availableOptions.length > 0 ? ( + availableOptions.map(option => ( + } + > + {option.value} + + )) + ) : ( + + No options available + + )} + + +
+ ); +}; + +TruncatedText.parameters = { + docs: { + description: { + story: + 'Text truncation is not built in, but is easy to achieve with CSS. This example truncates with an ' + + 'ellipsis both by container width and by a fixed width (50px).', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/index.stories.tsx new file mode 100644 index 0000000000000..345f62596f30e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/index.stories.tsx @@ -0,0 +1,32 @@ +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerButton, + TagPickerList, + TagPickerOption, +} from '@fluentui/react-headless-components-preview/tag-picker'; + +import descriptionMd from './TagPickerDescription.md'; + +export { Default } from './TagPickerDefault.stories'; +export { Filtering } from './TagPickerFiltering.stories'; +export { SecondaryAction } from './TagPickerSecondaryAction.stories'; +export { Grouped } from './TagPickerGrouped.stories'; +export { SingleSelect } from './TagPickerSingleSelect.stories'; +export { TruncatedText } from './TagPickerTruncatedText.stories'; +export { NoPopover } from './TagPickerNoPopover.stories'; + +export default { + title: 'Components/TagPicker', + component: TagPicker, + subcomponents: { TagPickerControl, TagPickerGroup, TagPickerInput, TagPickerButton, TagPickerList, TagPickerOption }, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/tag-picker.module.css b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/tag-picker.module.css new file mode 100644 index 0000000000000..95e5403397773 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/tag-picker.module.css @@ -0,0 +1,266 @@ +/* column wrapper for the whole example; positioning context for the popup */ +.demo { + display: flex; + flex-direction: column; + width: 100%; + max-width: 400px; + gap: var(--space-2); + position: relative; +} + +.label { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +/* the interactive control: holds the selected tags and the input */ +.control { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + min-width: 0; + min-height: 36px; + border-radius: var(--radius-md); + border: var(--stroke-thin) solid var(--border); + background: var(--bg-elev); + padding: var(--space-2) var(--space-3); + box-shadow: var(--shadow-1); + cursor: text; + transition: border-color var(--duration-fast) var(--ease-standard), + box-shadow var(--duration-fast) var(--ease-standard); +} + +.control:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.control[data-disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +/* group of selected tags — a focusgroup row */ +.group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); + min-width: 0; + max-width: 100%; +} + +.tag { + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-width: 0; + max-width: 100%; + border-radius: var(--radius-sm); + border: var(--stroke-thin) solid var(--border); + background: var(--surface-muted); + padding: 2px var(--space-2); + font-size: 12px; + color: var(--text); +} + +.tag:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); +} + +.dismissIcon { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-soft); + cursor: pointer; + padding: 0; + line-height: 1; +} + +.dismissIcon:hover { + color: var(--text); +} + +.dismissIcon:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent); + border-radius: var(--radius-pill); +} + +/* free-text input trigger */ +.input { + flex: 1 1 60px; + min-width: 60px; + border: none; + background: transparent; + outline: none; + font-size: 13px; + color: var(--text); + padding: var(--space-1) 0; +} + +.input::placeholder { + color: var(--text-faint); +} + +/* popup listbox — rendered in the top layer via the native `popover` attribute and anchored to the + control by `usePositioning`, so placement (position/inset) is owned by those, not this rule. */ +.listbox { + /* reset the user-agent [popover] centering so usePositioning's anchor-positioned offsets take effect */ + margin: 0; + max-height: 256px; + width: max-content; + box-sizing: border-box; + overflow: auto; + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: var(--space-1); + border: var(--stroke-thin) solid var(--border); + background: var(--bg-elev); + padding: var(--space-1); + font-size: 13px; + color: var(--text); + box-shadow: var(--shadow-4); +} + +/* the native popover is shown/hidden by the Popover API; ensure the closed state is hidden */ +.listbox:not(:popover-open) { + display: none; +} + +/* individual option row */ +.option { + display: flex; + align-items: center; + gap: var(--space-2); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + scroll-margin-block: var(--space-1); + color: var(--text); + cursor: default; + transition: background var(--duration-fast) var(--ease-standard); +} + +.option:hover { + background: var(--surface-muted); +} + +.option[data-activedescendant-focusvisible] { + outline: none; + box-shadow: 0 0 0 2px var(--accent); +} + +.option[data-selected] { + background: var(--accent-soft); + color: var(--accent-strong); + font-weight: 500; +} + +.option[data-disabled] { + cursor: not-allowed; + opacity: 0.5; +} + +.option[data-disabled]:hover { + background: transparent; +} + +/* circular "avatar" media used in tags and options */ +.media { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: var(--radius-pill); + background: var(--accent-soft); + color: var(--accent-strong); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* square media variant used inside option rows */ +.mediaSquare { + border-radius: var(--radius-sm); + width: 24px; + height: 24px; + font-size: 10px; +} + +/* option secondary text, pushed to the trailing edge */ +.secondaryContent { + margin-left: auto; + padding-left: var(--space-3); + color: var(--text-faint); + font-size: 11px; + white-space: nowrap; +} + +/* default trigger chevron */ +.expandIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--text-soft); +} + +/* secondary action button in the control aside */ +.secondaryAction { + border: none; + background: transparent; + color: var(--accent); + font-size: 12px; + font-weight: 600; + cursor: pointer; + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-2); +} + +.secondaryAction:hover { + background: var(--surface-muted); +} + +/* option group label + container */ +.optionGroup { + display: flex; + flex-direction: column; + gap: 1px; +} + +.optionGroupLabel { + display: block; + padding: var(--space-1) var(--space-3); + font-size: 11px; + font-weight: 600; + color: var(--text-faint); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +/* text truncation helpers (TruncatedText story) */ +.truncate { + display: block; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.truncateFixed { + width: 50px; + flex: 0 0 auto; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagPicker/utils.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/utils.tsx new file mode 100644 index 0000000000000..f73344d5ac5b6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/utils.tsx @@ -0,0 +1,60 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import * as React from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { TagPickerOption } from '@fluentui/react-headless-components-preview/tag-picker'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import type { TagProps } from '@fluentui/react-headless-components-preview/tag'; +import type { JSXElement } from '@fluentui/react-utilities'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tag-picker.module.css'; + +/** Returns up to two uppercase initials from a person's name. */ +export const getInitials = (name: string): string => + name + .split(' ') + .map(part => part[0]) + .filter(Boolean) + .slice(0, 2) + .join('') + .toUpperCase(); + +/** A lightweight avatar-like media badge used by the TagPicker stories. */ +export const Media = ({ name, square }: { name: string; square?: boolean }): JSXElement => ( + + {getInitials(name)} + +); + +/** A selected tag inside a `TagPickerGroup` (dismissal is wired by the group via context). */ +export const SelectedTag = ({ + value, + primaryText, +}: { + value: string; + primaryText?: TagProps['primaryText']; +}): JSXElement => ( + } + primaryText={primaryText} + dismissIcon={{ className: styles.dismissIcon, 'aria-label': 'remove', children: }} + > + {value} + +); + +/** A person option for the `TagPickerList`, with square media and secondary text. */ +export const PersonOption = ({ value }: { value: string }): JSXElement => ( + } + secondaryContent={{ className: styles.secondaryContent, children: 'Microsoft FTE' }} + > + {value} + +);