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 37a08d06cf0d0..e019dfd88c27a 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 @@ -39,6 +39,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'; @@ -88,6 +89,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..82c0d2516706c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/tag-picker.api.md @@ -0,0 +1,243 @@ +## 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 type { 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 type { OptionGroupState } 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 { PositioningShorthand } from '@fluentui/react-positioning'; +import type * as React_2 from 'react'; +import { renderTagPicker_unstable as renderTagPicker } from '@fluentui/react-tag-picker'; +import { renderTagPickerButton_unstable as renderTagPickerButton } from '@fluentui/react-tag-picker'; +import { renderTagPickerControl_unstable as renderTagPickerControl } from '@fluentui/react-tag-picker'; +import { renderTagPickerGroup_unstable as renderTagPickerGroup } from '@fluentui/react-tag-picker'; +import { renderTagPickerInput_unstable as renderTagPickerInput } from '@fluentui/react-tag-picker'; +import { renderTagPickerList_unstable as renderTagPickerList } from '@fluentui/react-tag-picker'; +import { renderTagPickerOption_unstable as renderTagPickerOption } from '@fluentui/react-tag-picker'; +import { renderOptionGroup_unstable as renderTagPickerOptionGroup } from '@fluentui/react-combobox'; +import type { Slot } from '@fluentui/react-utilities'; +import type { TagGroupBaseProps } from '@fluentui/react-tags'; +import type { TagGroupContextValue } from '@fluentui/react-tags'; +import type { TagPickerBaseProps } from '@fluentui/react-tag-picker'; +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 { TagPickerControlInternalSlots } from '@fluentui/react-tag-picker'; +import { TagPickerControlProps } from '@fluentui/react-tag-picker'; +import { TagPickerControlSlots } from '@fluentui/react-tag-picker'; +import type { TagPickerGroupBaseState } 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 { 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 { useTagPickerContextValues } from '@fluentui/react-tag-picker'; + +export { renderTagPicker } + +export { renderTagPickerButton } + +export { renderTagPickerControl } + +export { renderTagPickerGroup } + +export { renderTagPickerInput } + +export { renderTagPickerList } + +export { renderTagPickerOption } + +export { renderTagPickerOptionGroup } + +// @public +export const TagPicker: { + (props: TagPickerProps): JSXElement; + displayName: string; +}; + +// @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; + +export { TagPickerControlInternalSlots } + +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 & Pick; + +export { TagPickerGroupSlots } + +// @public +export type TagPickerGroupState = TagPickerGroupBaseState & { + 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 = ComponentProps & { + value: string; + disabled?: boolean; +} & ({ + text?: string; + children: string; +} | { + text: string; + children?: React_2.ReactNode; +}); + +// @public (undocumented) +export type TagPickerOptionSlots = Pick & { + media?: Slot<'div'>; + secondaryContent?: Slot<'span'>; +}; + +// @public +export type TagPickerOptionState = OptionState & ComponentState; + +// @public (undocumented) +export type TagPickerProps = TagPickerBaseProps & { + positioning?: PositioningShorthand; +}; + +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 } + +export { useTagPickerContextValues } + +// @public +export const useTagPickerControl: (props: TagPickerControlProps, ref: React_2.Ref) => TagPickerControlState; + +// @public (undocumented) +export function useTagPickerFilter({ filter: filterOverride, noOptionsElement, renderOption, query, options, }: UseTagPickerFilterConfig): JSXElement[]; + +// @public +export const useTagPickerGroup: (props: TagPickerGroupProps, ref: React_2.Ref) => TagPickerGroupState; + +// @public (undocumented) +export function useTagPickerGroupContextValues(state: TagPickerGroupState): TagGroupContextValues; + +// @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 f2e73bd38a5ae..4b276aaf8956d 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -59,6 +59,7 @@ "@fluentui/react-switch": "^9.7.4", "@fluentui/react-tabs": "^9.12.3", "@fluentui/react-tabster": "^9.26.16", + "@fluentui/react-tag-picker": "^9.10.0", "@fluentui/react-tags": "^9.9.2", "@fluentui/react-teaching-popover": "^9.7.2", "@fluentui/react-textarea": "^9.7.4", @@ -333,6 +334,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.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.cy.tsx new file mode 100644 index 0000000000000..85d4fcdd0390e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.cy.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { mount } from '@fluentui/scripts-cypress'; +import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill'; + +import { Tag } from '../Tag'; +import { TagPicker } from './TagPicker'; +import type { TagPickerProps } from './TagPicker.types'; +import { TagPickerControl } from './TagPickerControl'; +import { TagPickerGroup } from './TagPickerGroup'; +import { TagPickerInput } from './TagPickerInput'; +import { TagPickerList } from './TagPickerList'; +import { TagPickerOption } from './TagPickerOption'; + +polyfillBodyAndObserve(); + +const options = ['Cat', 'Dog', 'Ferret']; +const input = '[data-testid="tag-picker-input"]'; +const listbox = '[data-testid="tag-picker-list"]'; + +type TagPickerFixtureProps = Pick & { + defaultSelectedOptions?: string[]; +}; + +const TagPickerFixture = ({ defaultOpen, defaultSelectedOptions = [], noPopover }: TagPickerFixtureProps) => { + const [selectedOptions, setSelectedOptions] = React.useState(defaultSelectedOptions); + + return ( + <> + + setSelectedOptions(data.selectedOptions)} + > + + + {selectedOptions.map(option => ( + + {option} + + ))} + + + + {noPopover ? undefined : ( + + {options + .filter(option => !selectedOptions.includes(option)) + .map((option, index) => ( + + {option} + + ))} + + )} + + + + ); +}; + +describe('TagPicker', () => { + it('opens from the input and selects an option', () => { + mount(); + + cy.get(listbox).should('not.exist'); + cy.get(input).realClick(); + cy.get(listbox).should('exist'); + cy.get('[data-testid="option-Cat"]').realClick(); + cy.get('[data-testid="tag-Cat"]').should('exist'); + cy.get(listbox).should('not.be.visible'); + cy.get(input).should('have.attr', 'aria-expanded', 'false'); + cy.get(input).should('be.focused'); + }); + + it('uses active-descendant keyboard navigation', () => { + mount(); + + cy.get(input).focus().realPress('ArrowDown'); + cy.get(listbox).should('exist'); + cy.get(input).should('have.attr', 'aria-activedescendant', 'option-0').realPress('ArrowDown'); + cy.get(input).should('have.attr', 'aria-activedescendant', 'option-1').realPress('Enter'); + cy.get('[data-testid="tag-Dog"]').should('exist'); + }); + + it('moves from an empty input to the last tag with Backspace', () => { + mount(); + + cy.get(input).focus().realPress('Backspace'); + cy.get('[data-testid="tag-Dog"]').should('be.focused'); + }); + + it('uses focusgroup arrow navigation between tags', () => { + mount(); + + cy.get('[aria-label="Selected animals"]') + .should('have.attr', 'focusgroup', 'toolbar inline wrap') + .and('not.have.attr', 'data-tabster'); + cy.get('[data-testid="tag-Cat"]').should('have.attr', 'tabindex', '0').focus().realPress('ArrowRight'); + cy.get('[data-testid="tag-Dog"]').should('be.focused'); + cy.get('[data-testid="tag-Dog"]').realPress('ArrowRight'); + cy.get(input).should('be.focused'); + }); + + it('does not render a list when noPopover is enabled', () => { + mount(); + + cy.get(input).realClick(); + cy.get(listbox).should('not.exist'); + }); +}); 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..12cff439f018b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.test.tsx @@ -0,0 +1,198 @@ +import * as React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; +import { TagPicker } from './TagPicker'; +import type { TagPickerProps } from './TagPicker.types'; +import { TagPickerControl } from './TagPickerControl'; +import { TagPickerGroup } from './TagPickerGroup'; +import { useTagPickerGroupContextValues } from './TagPickerGroup'; +import { TagPickerInput } from './TagPickerInput'; +import { TagPickerList } from './TagPickerList'; +import { TagPickerOption } from './TagPickerOption'; +import { Tag } from '../Tag'; + +const renderTagPicker = (props: Partial> = {}) => { + const { selectedOptions = [], ...tagPickerProps } = 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('ArrowDown navigation sets aria-activedescendant to a rendered option', () => { + const { getByRole, getAllByRole } = renderTagPicker(); + const combobox = getByRole('combobox'); + + act(() => combobox.focus()); + fireEvent.keyDown(combobox, { key: 'ArrowDown' }); + + const activeId = combobox.getAttribute('aria-activedescendant'); + expect(activeId).toBeTruthy(); + + const activeOption = getAllByRole('option').find(o => o.id === activeId); + expect(activeOption).toBeDefined(); + }); + + it('renders selected options as tags and applies the focusgroup attribute on the group', () => { + const { getByRole } = renderTagPicker({ selectedOptions: ['Dog'] }); + + const group = getByRole('toolbar', { name: 'Selected animals' }); + expect(group).toHaveAttribute('focusgroup', 'toolbar inline wrap'); + expect(group).not.toHaveAttribute('data-tabster'); + expect(group).toHaveTextContent('Dog'); + }); + + it('does not render the group when nothing is selected', () => { + const { queryByRole } = renderTagPicker({ selectedOptions: [] }); + + expect(queryByRole('toolbar', { name: 'Selected animals' })).not.toBeInTheDocument(); + }); + + it('sets data-disabled on the control when disabled', () => { + const { getByRole } = renderTagPicker({ disabled: true }); + + expect(getByRole('combobox')).toBeDisabled(); + }); + + it.each(['ArrowLeft', 'Backspace'])('moves focus to the last tag on %s at the start of the input', key => { + const { getByRole, getByTestId } = renderTagPicker({ selectedOptions: ['Cat', 'Dog'] }); + const input = getByRole('combobox') as HTMLInputElement; + + act(() => input.focus()); + input.setSelectionRange(0, 0); + fireEvent.keyDown(input, { key }); + + expect(getByTestId('tag-Dog')).toHaveFocus(); + }); + + it('keeps focus in the input when Backspace is pressed away from the start', () => { + const { getByRole, getByTestId } = renderTagPicker({ selectedOptions: ['Dog'] }); + const input = getByRole('combobox') as HTMLInputElement; + + act(() => input.focus()); + fireEvent.change(input, { target: { value: 'Cat' } }); + input.setSelectionRange(2, 2); + fireEvent.keyDown(input, { key: 'Backspace' }); + + expect(input).toHaveFocus(); + expect(getByTestId('tag-Dog')).not.toHaveFocus(); + }); + + it('uses only the consumer positioning configuration', () => { + const { getByRole } = renderTagPicker({ + positioning: { position: 'above', align: 'end', fallbackPositions: ['below'] }, + }); + const listbox = getByRole('listbox'); + + expect(listbox).toHaveAttribute('data-placement', 'above-end'); + expect(listbox).toHaveStyle({ positionArea: 'block-start span-inline-start' }); + expect(listbox).toHaveStyle({ positionTryFallbacks: 'block-end' }); + expect(listbox).not.toHaveStyle({ width: 'anchor-size(width)' }); + }); + + it('does not apply a TagPicker-specific offset when positioning is omitted', () => { + const { getByRole } = renderTagPicker(); + + expect(getByRole('listbox')).toHaveStyle({ margin: '0px' }); + }); +}); + +describe('TagPickerGroup composition', () => { + it('defaults to role="toolbar" (the TagGroup base default) so consumers can override if needed', () => { + const { getByRole } = renderTagPicker({ selectedOptions: ['Cat'] }); + + expect(getByRole('toolbar', { name: 'Selected animals' })).toBeInTheDocument(); + }); + + it('allows consumers to override the default role via props', () => { + const { getByRole, queryByRole } = render( + + + + Cat + + + + + Cat + + , + ); + + expect(getByRole('group', { name: 'Selected animals' })).toBeInTheDocument(); + expect(queryByRole('toolbar', { name: 'Selected animals' })).not.toBeInTheDocument(); + }); + + it('makes tags dismissible by default via group context', () => { + const { getByTestId } = renderTagPicker({ selectedOptions: ['Cat', 'Dog'] }); + + // data-testid values come from renderTagPicker's `tag-${option}` pattern. + // data-dismissible is set on the Tag root by useTag when dismissible=true (the default). + expect(getByTestId('tag-Cat')).toHaveAttribute('data-dismissible'); + expect(getByTestId('tag-Dog')).toHaveAttribute('data-dismissible'); + }); + + it('allows consumers to suppress dismissal by passing dismissible={false}', () => { + const { getByTestId } = render( + + + + + Cat + + + Dog + + + + + + Cat + Dog + + , + ); + + // When dismissible={false} the group context sets dismissible=false, so useTag omits data-dismissible. + expect(getByTestId('tag-Cat')).not.toHaveAttribute('data-dismissible'); + expect(getByTestId('tag-Dog')).not.toHaveAttribute('data-dismissible'); + }); + + it('exports useTagPickerGroupContextValues as a composable hook', () => { + expect(typeof useTagPickerGroupContextValues).toBe('function'); + }); +}); 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..ef7274eba592b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.tsx @@ -0,0 +1,20 @@ +'use client'; + +import type { JSXElement } from '@fluentui/react-utilities'; +import { useTagPickerContextValues } from '@fluentui/react-tag-picker'; + +import { useTagPicker } from './useTagPicker'; +import { renderTagPicker } from './renderTagPicker'; +import type { TagPickerProps } from './TagPicker.types'; + +/** + * Headless TagPicker component. + */ +export const TagPicker = (props: TagPickerProps): JSXElement => { + 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..38b2c6115d349 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPicker.types.ts @@ -0,0 +1,15 @@ +import type { TagPickerBaseProps } from '@fluentui/react-tag-picker'; +import type { PositioningShorthand } from '../../positioning'; + +export type { + TagPickerState, + TagPickerContextValues, + TagPickerSlots, + TagPickerSize, + TagPickerOnOpenChangeData, + TagPickerOnOptionSelectData, +} from '@fluentui/react-tag-picker'; + +export type TagPickerProps = TagPickerBaseProps & { + positioning?: PositioningShorthand; +}; 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..a2279adabbfbc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerButton/renderTagPickerButton.tsx @@ -0,0 +1 @@ +export { renderTagPickerButton_unstable as renderTagPickerButton } from '@fluentui/react-tag-picker'; 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..3ac9f24b2a482 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/TagPickerControl.types.ts @@ -0,0 +1,23 @@ +import type { TagPickerControlBaseState } from '@fluentui/react-tag-picker'; + +export type { + TagPickerControlInternalSlots, + TagPickerControlProps, + TagPickerControlSlots, +} from '@fluentui/react-tag-picker'; + +/** + * 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..d668354a9602a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerControl/renderTagPickerControl.tsx @@ -0,0 +1 @@ +export { renderTagPickerControl_unstable as renderTagPickerControl } from '@fluentui/react-tag-picker'; 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..276993067111f --- /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 { useTagPickerGroupContextValues } from './useTagPickerGroupContextValues'; +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 = useTagPickerGroupContextValues(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..cfdf256a31ab9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/TagPickerGroup.types.ts @@ -0,0 +1,28 @@ +import type { ComponentProps } from '@fluentui/react-utilities'; +import type { TagGroupBaseProps } from '@fluentui/react-tags'; +import type { TagPickerGroupBaseState, TagPickerGroupSlots } from '@fluentui/react-tag-picker'; + +export type { TagPickerGroupSlots }; + +/** + * TagPickerGroup Props + */ +export type TagPickerGroupProps = ComponentProps & + Pick; + +/** + * State used in rendering the headless TagPickerGroup. + */ +export type TagPickerGroupState = TagPickerGroupBaseState & { + 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..0fc28a177820e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/index.ts @@ -0,0 +1,5 @@ +export { TagPickerGroup } from './TagPickerGroup'; +export { renderTagPickerGroup } from './renderTagPickerGroup'; +export { useTagPickerGroup } from './useTagPickerGroup'; +export { useTagPickerGroupContextValues } from './useTagPickerGroupContextValues'; +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..49886c562c9e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/renderTagPickerGroup.tsx @@ -0,0 +1 @@ +export { renderTagPickerGroup_unstable as renderTagPickerGroup } from '@fluentui/react-tag-picker'; 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..0b02c121b78ae --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/useTagPickerGroup.ts @@ -0,0 +1,61 @@ +'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 { isLastFocusableTag, stringifyDataAttribute } from '../../../utils'; + +/** + * 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( + { + disabled, + dismissible: true, + ...props, + onKeyDown: useEventCallback((event: React.KeyboardEvent) => { + props.onKeyDown?.(event); + if ( + isHTMLElement(event.target) && + event.key === ArrowRight && + isLastFocusableTag(event.currentTarget, event.target) + ) { + triggerRef.current?.focus(); + } + }), + onDismiss: useEventCallback((event, data) => { + props.onDismiss?.(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/TagPickerGroup/useTagPickerGroupContextValues.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/useTagPickerGroupContextValues.ts new file mode 100644 index 0000000000000..5401f1525939b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerGroup/useTagPickerGroupContextValues.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useTagGroupContextValues_unstable } from '@fluentui/react-tags'; + +import type { TagGroupContextValues } from '../../TagGroup/TagGroup.types'; +import type { TagPickerGroupState } from './TagPickerGroup.types'; + +export function useTagPickerGroupContextValues(state: TagPickerGroupState): TagGroupContextValues { + // Headless group state omits styled variants, but the shared tag context requires neutral defaults. + return useTagGroupContextValues_unstable({ + ...state, + appearance: 'filled', + size: 'medium', + }); +} 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..37ff26912b948 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/renderTagPickerInput.tsx @@ -0,0 +1 @@ +export { renderTagPickerInput_unstable as renderTagPickerInput } from '@fluentui/react-tag-picker'; 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..94c190ca6548e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerInput/useTagPickerInput.ts @@ -0,0 +1,38 @@ +'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 { focusLastTag, stringifyDataAttribute } from '../../../utils'; + +/** + * 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; +}; 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..9c90e38f84e00 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerList/renderTagPickerList.tsx @@ -0,0 +1 @@ +export { renderTagPickerList_unstable as renderTagPickerList } from '@fluentui/react-tag-picker'; 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.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.test.tsx new file mode 100644 index 0000000000000..732da9223d8a1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { TagPickerOption } from './TagPickerOption'; + +// --------------------------------------------------------------------------- +// Compile-time contract tests +// These assertions are checked by TypeScript during `tsc -p tsconfig.spec.json`. +// No runtime behaviour is exercised here; see TagPicker.test.tsx for integration +// tests that verify data-disabled, role="option", etc. +// --------------------------------------------------------------------------- + +describe('TagPickerOption type contract', () => { + it('accepts disabled as a valid prop (was missing before the fix)', () => { + // This must NOT be annotated @ts-expect-error: disabled is a valid public prop. + const el = ( + + Cat + + ); + expect(el).toBeDefined(); + }); + + it('accepts string children with optional text', () => { + const el = Cat; + expect(el).toBeDefined(); + }); + + it('accepts non-string children when text is provided', () => { + const el = ( + + Cat + + ); + expect(el).toBeDefined(); + }); + + it('compile-time: value is required', () => { + // @ts-expect-error value is required + const el = Cat; + expect(el).toBeDefined(); + }); + + it('compile-time: text is required when children is not a plain string', () => { + const el = ( + // @ts-expect-error text is required when children is non-string + + Cat + + ); + expect(el).toBeDefined(); + }); +}); 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..889102a909fb1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.tsx @@ -0,0 +1,19 @@ +'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. + */ +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..0c8753248ce9b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/TagPickerOption.types.ts @@ -0,0 +1,59 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { OptionSlots, OptionState } from '../../Dropdown/Option'; +import type * as React from 'react'; + +// Only the root slot is surfaced in TagPickerOptionSlots. +// checkIcon (the other OptionSlots member) is an internal rendering detail managed by +// useOption / useOptionBase_unstable; exposing it here would force every consumer to +// supply it, and it is not part of the upstream TagPickerOption public API. +export type TagPickerOptionSlots = Pick & { + /** + * Media rendered before the option's text content (e.g. an avatar or icon). + */ + media?: Slot<'div'>; + /** + * Secondary text rendered after the option's text content. + */ + secondaryContent?: Slot<'span'>; +}; + +/** + * TagPickerOption Props + * + * Uses ComponentProps so all public slots and root attributes are + * represented by the component's own slot contract. + */ +export type TagPickerOptionProps = ComponentProps & { + /** + * Unique string value for this option, used to track selection state. + */ + value: string; + /** + * Sets an option to the `disabled` state. + * Disabled options cannot be selected, but are still keyboard navigable. + */ + disabled?: boolean; +} & ( + | { + /** + * An optional override for the string value of the option's display text, + * defaulting to the option's child content. + */ + text?: string; + children: string; + } + | { + /** + * The string value of the option's display text when its children are not a string. + */ + text: string; + children?: React.ReactNode; + } + ); + +/** + * State used in rendering the headless TagPickerOption. + * OptionState is retained (rather than the narrower upstream state) so that + * disabled/selected/checkIcon rendering from useOption is available at render time. + */ +export type TagPickerOptionState = OptionState & ComponentState; 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..2da9ae7dc2686 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/renderTagPickerOption.tsx @@ -0,0 +1 @@ +export { renderTagPickerOption_unstable as renderTagPickerOption } from '@fluentui/react-tag-picker'; 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..3146fca46868b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/TagPickerOption/useTagPickerOption.ts @@ -0,0 +1,41 @@ +'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. + * + */ +export const useTagPickerOption = (props: TagPickerOptionProps, ref: React.Ref): TagPickerOptionState => { + const optionState = useOption(props, ref); + + /* eslint-disable react-hooks/immutability -- decorate the base option state */ + // The shared TagPicker base currently discovers active-descendant options by this class. + optionState.root.className = optionState.root.className + ? `${optionClassNames.root} ${optionState.root.className}` + : optionClassNames.root; + /* eslint-enable react-hooks/immutability */ + + return { + ...optionState, + components: { + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...optionState.components, + media: 'div', + secondaryContent: 'span', + }, + media: slot.optional(props.media, { elementType: 'div' }), + secondaryContent: slot.optional(props.secondaryContent, { elementType: 'span' }), + // Force role="option": useOption / useOptionBase_unstable may emit role="menuitemcheckbox" in + // multiselect mode, but TagPickerList is a listbox whose items must be role="option". + root: slot.always( + { ...optionState.root, role: 'option', 'aria-checked': props['aria-checked'] }, + { elementType: 'div' }, + ), + }; +}; 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..8087f8ed6b988 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/index.ts @@ -0,0 +1,53 @@ +export { TagPicker } from './TagPicker'; +export { renderTagPicker } from './renderTagPicker'; +export { useTagPicker } from './useTagPicker'; +export { useTagPickerFilter } from './useTagPickerFilter'; +export { useTagPickerContextValues } from '@fluentui/react-tag-picker'; +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, + useTagPickerGroupContextValues, +} 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..f04002f56fbbd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPicker.ts @@ -0,0 +1,29 @@ +'use client'; + +import { useMergedRefs } from '@fluentui/react-utilities'; +import { useTagPickerBase_unstable } from '@fluentui/react-tag-picker'; + +import { resolvePositioningShorthand, usePositioning } from '../../positioning'; +import type { TagPickerProps, TagPickerState } from './TagPicker.types'; + +/** + * Returns the state for a headless TagPicker. + */ +export const useTagPicker = (props: TagPickerProps): TagPickerState => { + const { positioning, ...baseProps } = props; + const { targetRef, containerRef } = usePositioning(resolvePositioningShorthand(positioning)); + + const baseState = useTagPickerBase_unstable(baseProps); + const popoverRef = useMergedRefs(baseState.popoverRef, containerRef); + + return { + ...baseState, + // Required by the shared styled TagPicker state/context contract. Headless base hooks do not + // consume these presentation values; inline is behavioral and keeps the popover in DOM order. + appearance: 'outline', + inline: true, + size: 'medium', + targetRef: targetRef as unknown as TagPickerState['targetRef'], + popoverRef, + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.test.tsx new file mode 100644 index 0000000000000..5b6ef0e7aac75 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.test.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; + +import { useTagPickerFilter } from '../../tag-picker'; +import { TagPickerOption } from './TagPickerOption'; + +describe('useTagPickerFilter', () => { + it('renders headless TagPickerOption elements by default', () => { + const noOptionsElement = No options; + const { result } = renderHook(() => + useTagPickerFilter({ + query: '', + options: ['Cat'], + noOptionsElement, + }), + ); + + expect(result.current[0].type).toBe(TagPickerOption); + }); + + it('uses a caller-provided option renderer', () => { + const noOptionsElement = No options; + const renderOption = (option: string) => Custom {option}; + const { result } = renderHook(() => + useTagPickerFilter({ + query: '', + options: ['Cat'], + noOptionsElement, + renderOption, + }), + ); + + expect(result.current[0].type).toBe('span'); + expect(result.current[0].key).toBe('Cat'); + expect(React.Children.toArray(result.current[0].props.children).join('')).toBe('Custom Cat'); + }); + + it('filters options using the query by default', () => { + const noOptionsElement = No options; + const { result } = renderHook(() => + useTagPickerFilter({ + query: 'at', + options: ['Cat', 'Dog'], + noOptionsElement, + }), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].props.value).toBe('Cat'); + }); + + it('forwards option indexes to a caller-provided filter', () => { + const filter = jest.fn((_option: string, index: number) => index === 1); + const noOptionsElement = No options; + const { result } = renderHook(() => + useTagPickerFilter({ + query: '', + options: ['Cat', 'Dog'], + filter, + noOptionsElement, + }), + ); + + expect(filter).toHaveBeenNthCalledWith(1, 'Cat', 0); + expect(filter).toHaveBeenNthCalledWith(2, 'Dog', 1); + expect(result.current[0].props.value).toBe('Dog'); + }); + + it('adds a stable key to an unkeyed no-options element', () => { + const noOptionsElement = No options; + const { result } = renderHook(() => + useTagPickerFilter({ + query: '', + options: [], + noOptionsElement, + }), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].key).toBe('no-options'); + }); + + it('preserves the key of a keyed no-options element', () => { + const noOptionsElement = No options; + const { result } = renderHook(() => + useTagPickerFilter({ + query: '', + options: [], + noOptionsElement, + }), + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toBe(noOptionsElement); + expect(result.current[0].key).toBe('custom-no-options'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.tsx new file mode 100644 index 0000000000000..e559eb0516ca9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagPicker/useTagPickerFilter.tsx @@ -0,0 +1,53 @@ +'use client'; + +import * as React from 'react'; +import type { JSXElement } from '@fluentui/react-utilities'; + +import { TagPickerOption } from './TagPickerOption'; + +type UseTagPickerFilterConfig = { + query: string; + options: string[]; + filter?: (option: string, index: number) => boolean; + noOptionsElement: JSXElement; + renderOption?: (option: string) => JSXElement; +}; + +function defaultRenderOption(option: string): JSXElement { + return ( + + {option} + + ); +} + +export function useTagPickerFilter({ + filter: filterOverride, + noOptionsElement, + renderOption = defaultRenderOption, + query, + options, +}: UseTagPickerFilterConfig): JSXElement[] { + const defaultFilter = React.useCallback( + (option: string) => { + const trimmedQuery = query.trim(); + return trimmedQuery === '' || option.toLowerCase().includes(trimmedQuery.toLowerCase()); + }, + [query], + ); + const filter = filterOverride ?? defaultFilter; + const filteredOptions = React.useMemo( + () => + options.reduce((accumulator, option, index) => { + if (filter(option, index)) { + accumulator.push(renderOption(option)); + } + return accumulator; + }, []), + [options, renderOption, filter], + ); + + return filteredOptions.length === 0 + ? [noOptionsElement.key ? noOptionsElement : React.cloneElement(noOptionsElement, { key: 'no-options' })] + : filteredOptions; +} 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..712e9a9c43db0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/tag-picker.ts @@ -0,0 +1,63 @@ +export { + TagPicker, + renderTagPicker, + useTagPicker, + useTagPickerFilter, + useTagPickerContextValues, + TagPickerControl, + renderTagPickerControl, + useTagPickerControl, + TagPickerInput, + renderTagPickerInput, + useTagPickerInput, + TagPickerButton, + renderTagPickerButton, + useTagPickerButton, + TagPickerGroup, + renderTagPickerGroup, + useTagPickerGroup, + useTagPickerGroupContextValues, + 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'; + +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/library/src/utils/index.ts b/packages/react-components/react-headless-components-preview/library/src/utils/index.ts index 3ecb16348baa8..3ef52e1d67cb9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/utils/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/utils/index.ts @@ -1 +1,2 @@ export { stringifyDataAttribute } from './stringifyDataAttribute'; +export { focusLastTag, isLastFocusableTag } from './tagFocusUtils'; diff --git a/packages/react-components/react-headless-components-preview/library/src/utils/tagFocusUtils.test.ts b/packages/react-components/react-headless-components-preview/library/src/utils/tagFocusUtils.test.ts new file mode 100644 index 0000000000000..991ce8ba93167 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/utils/tagFocusUtils.test.ts @@ -0,0 +1,136 @@ +import { focusLastTag, isLastFocusableTag } from './tagFocusUtils'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeContainer(...children: HTMLElement[]): HTMLElement { + const container = document.createElement('div'); + children.forEach(child => container.appendChild(child)); + return container; +} + +function makeButton(): HTMLButtonElement { + return document.createElement('button'); +} + +function makeTagSpan(tabIndex: number = -1): HTMLSpanElement { + const span = document.createElement('span'); + span.setAttribute('tabindex', String(tabIndex)); + return span; +} + +// --------------------------------------------------------------------------- +// focusLastTag +// --------------------------------------------------------------------------- + +describe('focusLastTag', () => { + it('does nothing when the container is empty', () => { + const container = makeContainer(); + expect(() => focusLastTag(container)).not.toThrow(); + }); + + it('focuses the single button in the container', () => { + const btn = makeButton(); + const focusSpy = jest.spyOn(btn, 'focus'); + const container = makeContainer(btn); + + focusLastTag(container); + + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + it('focuses the last button when multiple buttons are present', () => { + const first = makeButton(); + const last = makeButton(); + const firstFocusSpy = jest.spyOn(first, 'focus'); + const lastFocusSpy = jest.spyOn(last, 'focus'); + const container = makeContainer(first, last); + + focusLastTag(container); + + expect(firstFocusSpy).not.toHaveBeenCalled(); + expect(lastFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('focuses the last [tabindex] element when no buttons are present', () => { + const first = makeTagSpan(); + const last = makeTagSpan(); + const lastFocusSpy = jest.spyOn(last, 'focus'); + const container = makeContainer(first, last); + + focusLastTag(container); + + expect(lastFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('focuses the last element in a mixed buttons / tabindex group', () => { + const btn = makeButton(); + const tag = makeTagSpan(); + const tagFocusSpy = jest.spyOn(tag, 'focus'); + const container = makeContainer(btn, tag); + + focusLastTag(container); + + expect(tagFocusSpy).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// isLastFocusableTag +// --------------------------------------------------------------------------- + +describe('isLastFocusableTag', () => { + it('returns false when the container is empty', () => { + const container = makeContainer(); + const target = makeButton(); + + expect(isLastFocusableTag(container, target)).toBe(false); + }); + + it('returns true when target is the only focusable element', () => { + const btn = makeButton(); + const container = makeContainer(btn); + + expect(isLastFocusableTag(container, btn)).toBe(true); + }); + + it('returns true when target is the last of multiple buttons', () => { + const first = makeButton(); + const last = makeButton(); + const container = makeContainer(first, last); + + expect(isLastFocusableTag(container, last)).toBe(true); + }); + + it('returns false when target is NOT the last focusable element', () => { + const first = makeButton(); + const last = makeButton(); + const container = makeContainer(first, last); + + expect(isLastFocusableTag(container, first)).toBe(false); + }); + + it('returns true when target is the last [tabindex] element', () => { + const first = makeTagSpan(); + const last = makeTagSpan(); + const container = makeContainer(first, last); + + expect(isLastFocusableTag(container, last)).toBe(true); + }); + + it('handles roving tabindex values (tabindex=-1 items are still matched)', () => { + const tag = makeTagSpan(-1); + const container = makeContainer(tag); + + expect(isLastFocusableTag(container, tag)).toBe(true); + }); + + it('returns false when target is not in the container at all', () => { + const inContainer = makeButton(); + const outside = makeButton(); + const container = makeContainer(inContainer); + + expect(isLastFocusableTag(container, outside)).toBe(false); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/utils/tagFocusUtils.ts b/packages/react-components/react-headless-components-preview/library/src/utils/tagFocusUtils.ts new file mode 100644 index 0000000000000..346990d85da4a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/utils/tagFocusUtils.ts @@ -0,0 +1,33 @@ +/** + * CSS selector used to locate focusable tag elements inside a TagPickerGroup. + * + * The focusgroup polyfill manages roving `tabindex` on the tags, so items carry + * `tabindex="-1"` and remain programmatically focusable without filtering by value. + */ +const FOCUSABLE_TAG_SELECTOR = 'button, [tabindex]'; + +/** + * Returns the NodeList of focusable tag elements within `container`, or an empty + * NodeList when the container has no matching children. + */ +function getFocusableTags(container: HTMLElement): NodeListOf { + return container.querySelectorAll(FOCUSABLE_TAG_SELECTOR); +} + +/** + * Moves focus to the last focusable element within the tag group container. + * Does nothing when the container is empty or contains no focusable tags. + */ +export function focusLastTag(container: HTMLElement): void { + const focusable = getFocusableTags(container); + focusable[focusable.length - 1]?.focus(); +} + +/** + * Returns `true` when `target` is the last focusable element within the tag + * group container; `false` otherwise (including when the container is empty). + */ +export function isLastFocusableTag(container: HTMLElement, target: HTMLElement): boolean { + const focusable = getFocusableTags(container); + return focusable.length > 0 && focusable[focusable.length - 1] === target; +} 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..f67c16c624665 --- /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, tagPickerPositioning } 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..aaddb69f840a9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerDescription.md @@ -0,0 +1,29 @@ +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 headless TagPicker renders its native popover in DOM order and positions it with the package's +CSS anchor-based `usePositioning` hook. Use the optional `positioning` prop to define placement and +any desired alignment, offset, fallback positions, or target-size matching. The component does not +apply the styled TagPicker's presentation defaults; these stories explicitly add an offset for visual +spacing. Consumers do not need to pass an `inline` prop. + +### 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..04a36c41047bd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerFiltering.stories.tsx @@ -0,0 +1,114 @@ +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, tagPickerPositioning } 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 and renders headless options by default.', + }, + }, +}; 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..d9130901e3e62 --- /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, tagPickerPositioning } 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..c765eafbbf799 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerNoPopover.stories.tsx @@ -0,0 +1,68 @@ +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, tagPickerPositioning } 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..987022834ce34 --- /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, tagPickerPositioning } 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..689fbf6593155 --- /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, tagPickerPositioning } 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..501576985aebc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/TagPickerTruncatedText.stories.tsx @@ -0,0 +1,108 @@ +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, tagPickerPositioning } 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..0de4df1661294 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/index.stories.tsx @@ -0,0 +1,34 @@ +import { + TagPicker, + TagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerButton, + TagPickerList, + TagPickerOption, +} from '@fluentui/react-headless-components-preview/tag-picker'; + +import descriptionMd from './TagPickerDescription.md'; + +import { getBrowserSupportNotice } from '../shared/browserSupportNotice'; + +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, getBrowserSupportNotice('TagPicker')].join('\n'), + }, + }, + }, +}; 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..6f8850a7a365a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagPicker/utils.tsx @@ -0,0 +1,69 @@ +// 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'; +import type { TagPickerProps } 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'; + +export const tagPickerPositioning: TagPickerProps['positioning'] = { + position: 'below', + align: 'start', + offset: { crossAxis: 0, mainAxis: 2 }, + fallbackPositions: ['above', 'after', 'after-top', 'before', 'before-top'], + matchTargetSize: 'width', +}; + +/** 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} + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts index e23b5b845834b..14e13b74f40a3 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts +++ b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts @@ -14,6 +14,7 @@ const COMPONENT_FEATURES: Record = { Menu: ['popover', 'anchor-positioning', 'focusgroup'], Dropdown: ['popover', 'anchor-positioning'], Combobox: ['popover', 'anchor-positioning'], + TagPicker: ['popover', 'anchor-positioning', 'focusgroup'], Dialog: ['dialog', 'popover'], Drawer: ['dialog', 'popover'], Toast: ['popover'], diff --git a/yarn.lock b/yarn.lock index 067f9f28f0bc0..ca5fc950bc78c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4312,6 +4312,7 @@ __metadata: "@fluentui/react-switch": "npm:^9.7.4" "@fluentui/react-tabs": "npm:^9.12.3" "@fluentui/react-tabster": "npm:^9.26.16" + "@fluentui/react-tag-picker": "npm:^9.10.0" "@fluentui/react-tags": "npm:^9.9.2" "@fluentui/react-teaching-popover": "npm:^9.7.2" "@fluentui/react-textarea": "npm:^9.7.4"