From bec6fbe045d558d74a48cd086aa200e475d0f85c Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 14:43:19 +0200 Subject: [PATCH 1/9] feat(react-headless-components-preview): add headless Tag, InteractionTag, InteractionTagPrimary, InteractionTagSecondary, TagGroup --- .../etc/interaction-tag-primary.api.md | 47 ++++++++++++++++ .../etc/interaction-tag-secondary.api.md | 39 ++++++++++++++ .../library/etc/interaction-tag.api.md | 50 +++++++++++++++++ .../library/etc/tag-group.api.md | 49 +++++++++++++++++ .../library/etc/tag.api.md | 47 ++++++++++++++++ .../library/package.json | 30 +++++++++++ .../InteractionTag/InteractionTag.test.tsx | 20 +++++++ .../InteractionTag/InteractionTag.tsx | 20 +++++++ .../InteractionTag/InteractionTag.types.ts | 28 ++++++++++ .../src/components/InteractionTag/index.ts | 9 ++++ .../InteractionTag/renderInteractionTag.ts | 6 +++ .../InteractionTag/useInteractionTag.ts | 54 +++++++++++++++++++ .../InteractionTagPrimary.test.tsx | 21 ++++++++ .../InteractionTagPrimary.tsx | 19 +++++++ .../InteractionTagPrimary.types.ts | 31 +++++++++++ .../components/InteractionTagPrimary/index.ts | 9 ++++ .../renderInteractionTagPrimary.ts | 6 +++ .../useInteractionTagPrimary.ts | 44 +++++++++++++++ .../InteractionTagSecondary.test.tsx | 28 ++++++++++ .../InteractionTagSecondary.tsx | 22 ++++++++ .../InteractionTagSecondary.types.ts | 23 ++++++++ .../InteractionTagSecondary/index.ts | 8 +++ .../renderInteractionTagSecondary.ts | 6 +++ .../useInteractionTagSecondary.ts | 29 ++++++++++ .../library/src/components/Tag/Tag.test.tsx | 35 ++++++++++++ .../library/src/components/Tag/Tag.tsx | 19 +++++++ .../library/src/components/Tag/Tag.types.ts | 31 +++++++++++ .../library/src/components/Tag/index.ts | 4 ++ .../library/src/components/Tag/renderTag.ts | 6 +++ .../library/src/components/Tag/useTag.ts | 35 ++++++++++++ .../src/components/TagGroup/TagGroup.test.tsx | 45 ++++++++++++++++ .../src/components/TagGroup/TagGroup.tsx | 20 +++++++ .../src/components/TagGroup/TagGroup.types.ts | 28 ++++++++++ .../library/src/components/TagGroup/index.ts | 4 ++ .../src/components/TagGroup/renderTagGroup.ts | 6 +++ .../src/components/TagGroup/useTagGroup.ts | 52 ++++++++++++++++++ .../library/src/interaction-tag-primary.ts | 12 +++++ .../library/src/interaction-tag-secondary.ts | 10 ++++ .../library/src/interaction-tag.ts | 12 +++++ .../library/src/tag-group.ts | 2 + .../library/src/tag.ts | 2 + 41 files changed, 968 insertions(+) create mode 100644 packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/etc/tag.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tag/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/TagGroup/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/interaction-tag-primary.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/interaction-tag-secondary.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/interaction-tag.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/tag-group.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/tag.ts diff --git a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md new file mode 100644 index 00000000000000..29546a5a5740e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md @@ -0,0 +1,47 @@ +## 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 { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { InteractionTagPrimaryBaseProps } from '@fluentui/react-tags'; +import { InteractionTagPrimaryBaseState } from '@fluentui/react-tags'; +import { InteractionTagPrimaryContextValues as InteractionTagPrimaryContextValues_2 } from '@fluentui/react-tags'; +import type { InteractionTagPrimarySlots as InteractionTagPrimarySlots_2 } from '@fluentui/react-tags'; +import { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; + +// @public +export const InteractionTagPrimary: ForwardRefComponent; + +// @public (undocumented) +export type InteractionTagPrimaryContextValues = InteractionTagPrimaryContextValues_2; + +// @public (undocumented) +export type InteractionTagPrimaryProps = InteractionTagPrimaryBaseProps; + +// @public (undocumented) +export type InteractionTagPrimarySlots = InteractionTagPrimarySlots_2; + +// @public (undocumented) +export type InteractionTagPrimaryState = InteractionTagPrimaryBaseState & { + root: { + 'data-disabled'?: string; + 'data-selected'?: string; + 'data-has-secondary-action'?: string; + }; +}; + +// @public +export const renderInteractionTagPrimary: (state: InteractionTagPrimaryBaseState, contextValues: InteractionTagPrimaryContextValues_2) => JSXElement; + +// @public +export const useInteractionTagPrimary: (props: InteractionTagPrimaryProps, ref: React_2.Ref) => InteractionTagPrimaryState; + +// @public +export const useInteractionTagPrimaryContextValues: (_state: InteractionTagPrimaryState) => InteractionTagPrimaryContextValues; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md new file mode 100644 index 00000000000000..e14e5d2b65f938 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md @@ -0,0 +1,39 @@ +## 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 { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { InteractionTagSecondaryBaseProps } from '@fluentui/react-tags'; +import { InteractionTagSecondaryBaseState } from '@fluentui/react-tags'; +import type { InteractionTagSecondarySlots as InteractionTagSecondarySlots_2 } from '@fluentui/react-tags'; +import { JSXElement } from '@fluentui/react-utilities'; +import type * as React_2 from 'react'; + +// @public +export const InteractionTagSecondary: ForwardRefComponent; + +// @public (undocumented) +export type InteractionTagSecondaryProps = InteractionTagSecondaryBaseProps; + +// @public (undocumented) +export type InteractionTagSecondarySlots = InteractionTagSecondarySlots_2; + +// @public (undocumented) +export type InteractionTagSecondaryState = InteractionTagSecondaryBaseState & { + root: { + 'data-disabled'?: string; + 'data-selected'?: string; + }; +}; + +// @public +export const renderInteractionTagSecondary: (state: InteractionTagSecondaryBaseState) => JSXElement; + +// @public +export const useInteractionTagSecondary: (props: InteractionTagSecondaryProps, ref: React_2.Ref) => InteractionTagSecondaryState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md new file mode 100644 index 00000000000000..fefa3de53ec1e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md @@ -0,0 +1,50 @@ +## 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 { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { InteractionTagBaseProps } from '@fluentui/react-tags'; +import { InteractionTagBaseState } from '@fluentui/react-tags'; +import { InteractionTagContextValue } from '@fluentui/react-tags'; +import type { InteractionTagSlots as InteractionTagSlots_2 } from '@fluentui/react-tags'; +import { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; + +// @public +export const InteractionTag: ForwardRefComponent; + +// @public (undocumented) +export type InteractionTagContextValues = { + interactionTag: InteractionTagContextValue; +}; + +// @public (undocumented) +export type InteractionTagProps = InteractionTagBaseProps; + +// @public (undocumented) +export type InteractionTagSlots = InteractionTagSlots_2; + +// @public (undocumented) +export type InteractionTagState = InteractionTagBaseState & { + root: { + 'data-disabled'?: string; + 'data-selected'?: string; + }; +}; + +// @public +export const renderInteractionTag: (state: InteractionTagBaseState, contextValues: { + interactionTag: InteractionTagContextValue; +}) => JSXElement; + +// @public +export const useInteractionTag: (props: InteractionTagProps, ref: React_2.Ref) => InteractionTagState; + +// @public +export const useInteractionTagContextValues: (state: InteractionTagState) => InteractionTagContextValues; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md new file mode 100644 index 00000000000000..43fee1ba553336 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md @@ -0,0 +1,49 @@ +## 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 { ForwardRefComponent } from '@fluentui/react-utilities'; +import { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; +import type { TagGroupBaseProps } from '@fluentui/react-tags'; +import { TagGroupBaseState } from '@fluentui/react-tags'; +import type { TagGroupContextValue } from '@fluentui/react-tags'; +import { TagGroupContextValues as TagGroupContextValues_2 } from '@fluentui/react-tags'; +import type { TagGroupSlots as TagGroupSlots_2 } from '@fluentui/react-tags'; + +// @public +export const renderTagGroup: (state: TagGroupBaseState, contextValue: TagGroupContextValues_2) => JSXElement; + +// @public +export const TagGroup: ForwardRefComponent; + +// @public (undocumented) +export type TagGroupContextValues = { + tagGroup: TagGroupContextValue; +}; + +// @public (undocumented) +export type TagGroupProps = TagGroupBaseProps; + +// @public (undocumented) +export type TagGroupSlots = TagGroupSlots_2; + +// @public (undocumented) +export type TagGroupState = TagGroupBaseState & { + root: { + 'data-disabled'?: string; + 'data-dismissible'?: string; + }; +}; + +// @public +export const useTagGroup: (props: TagGroupProps, ref: React_2.Ref) => TagGroupState; + +// @public +export const useTagGroupContextValues: (state: TagGroupState) => TagGroupContextValues; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag.api.md new file mode 100644 index 00000000000000..fb89aea49539cc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/tag.api.md @@ -0,0 +1,47 @@ +## 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 { ForwardRefComponent } from '@fluentui/react-utilities'; +import { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; +import type { TagBaseProps } from '@fluentui/react-tags'; +import { TagBaseState } from '@fluentui/react-tags'; +import { TagContextValues as TagContextValues_2 } from '@fluentui/react-tags'; +import type { TagSlots as TagSlots_2 } from '@fluentui/react-tags'; + +// @public +export const renderTag: (state: TagBaseState, contextValues: TagContextValues_2) => JSXElement; + +// @public +export const Tag: ForwardRefComponent; + +// @public (undocumented) +export type TagContextValues = TagContextValues_2; + +// @public (undocumented) +export type TagProps = TagBaseProps; + +// @public (undocumented) +export type TagSlots = TagSlots_2; + +// @public (undocumented) +export type TagState = TagBaseState & { + root: { + 'data-disabled'?: string; + 'data-dismissible'?: string; + 'data-selected'?: string; + }; +}; + +// @public +export const useTag: (props: TagProps, ref: React_2.Ref) => TagState; + +// @public +export const useTagContextValues: (_state: TagState) => TagContextValues; + +// (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 03c160dadebea6..ed0810e8409448 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -161,6 +161,24 @@ "import": "./lib/input.js", "require": "./lib-commonjs/input.js" }, + "./interaction-tag": { + "types": "./dist/interaction-tag.d.ts", + "node": "./lib-commonjs/interaction-tag.js", + "import": "./lib/interaction-tag.js", + "require": "./lib-commonjs/interaction-tag.js" + }, + "./interaction-tag-primary": { + "types": "./dist/interaction-tag-primary.d.ts", + "node": "./lib-commonjs/interaction-tag-primary.js", + "import": "./lib/interaction-tag-primary.js", + "require": "./lib-commonjs/interaction-tag-primary.js" + }, + "./interaction-tag-secondary": { + "types": "./dist/interaction-tag-secondary.d.ts", + "node": "./lib-commonjs/interaction-tag-secondary.js", + "import": "./lib/interaction-tag-secondary.js", + "require": "./lib-commonjs/interaction-tag-secondary.js" + }, "./label": { "types": "./dist/label.d.ts", "node": "./lib-commonjs/label.js", @@ -281,6 +299,18 @@ "import": "./lib/tab-list.js", "require": "./lib-commonjs/tab-list.js" }, + "./tag": { + "types": "./dist/tag.d.ts", + "node": "./lib-commonjs/tag.js", + "import": "./lib/tag.js", + "require": "./lib-commonjs/tag.js" + }, + "./tag-group": { + "types": "./dist/tag-group.d.ts", + "node": "./lib-commonjs/tag-group.js", + "import": "./lib/tag-group.js", + "require": "./lib-commonjs/tag-group.js" + }, "./textarea": { "types": "./dist/textarea.d.ts", "node": "./lib-commonjs/textarea.js", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.test.tsx new file mode 100644 index 00000000000000..af2ed781237686 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.test.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { InteractionTag } from './InteractionTag'; +import { InteractionTagPrimary } from '../InteractionTagPrimary'; + +const requiredProps = { children: tag }; + +describe('InteractionTag', () => { + isConformant({ + Component: InteractionTag, + displayName: 'InteractionTag', + requiredProps, + }); + + it('provides a child InteractionTagPrimary with an aria-pressed when handleTagSelect is wired', () => { + const result = render(); + expect(result.getByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.tsx new file mode 100644 index 00000000000000..7f560f967adbfd --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { InteractionTagProps } from './InteractionTag.types'; +import { useInteractionTag, useInteractionTagContextValues } from './useInteractionTag'; +import { renderInteractionTag } from './renderInteractionTag'; + +/** + * A visual representation of an attribute that can have primary and/or secondary actions. + * Composed with `InteractionTagPrimary` and optionally `InteractionTagSecondary` children. + */ +export const InteractionTag: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useInteractionTag(props, ref); + const contextValues = useInteractionTagContextValues(state); + return renderInteractionTag(state, contextValues); +}); + +InteractionTag.displayName = 'InteractionTag'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.types.ts new file mode 100644 index 00000000000000..717761b703b33e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/InteractionTag.types.ts @@ -0,0 +1,28 @@ +import type { + InteractionTagSlots as InteractionTagBaseSlots, + InteractionTagBaseProps, + InteractionTagBaseState, +} from '@fluentui/react-tags'; +import type { InteractionTagContextValue } from '@fluentui/react-tags'; + +export type InteractionTagSlots = InteractionTagBaseSlots; + +export type InteractionTagProps = InteractionTagBaseProps; + +export type InteractionTagState = InteractionTagBaseState & { + root: { + /** + * Data attribute set when the interaction tag is disabled. + */ + 'data-disabled'?: string; + + /** + * Data attribute set when the interaction tag is selected. + */ + 'data-selected'?: string; + }; +}; + +export type InteractionTagContextValues = { + interactionTag: InteractionTagContextValue; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/index.ts new file mode 100644 index 00000000000000..28c9b671cb4766 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/index.ts @@ -0,0 +1,9 @@ +export { InteractionTag } from './InteractionTag'; +export { renderInteractionTag } from './renderInteractionTag'; +export { useInteractionTag, useInteractionTagContextValues } from './useInteractionTag'; +export type { + InteractionTagSlots, + InteractionTagProps, + InteractionTagState, + InteractionTagContextValues, +} from './InteractionTag.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts new file mode 100644 index 00000000000000..2bdfeebbd7ecab --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts @@ -0,0 +1,6 @@ +import { renderInteractionTag_unstable } from '@fluentui/react-tags'; + +/** + * Renders the final JSX of the InteractionTag component, given the state and context values. + */ +export const renderInteractionTag = renderInteractionTag_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts new file mode 100644 index 00000000000000..34610bc7ba119d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts @@ -0,0 +1,54 @@ +'use client'; + +import * as React from 'react'; +import { useInteractionTagBase_unstable } from '@fluentui/react-tags'; +import type { InteractionTagContextValue } from '@fluentui/react-tags'; + +import type { InteractionTagContextValues, InteractionTagProps, InteractionTagState } from './InteractionTag.types'; +import { stringifyDataAttribute } from '../../utils'; + +/** + * Returns the state for an InteractionTag component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderInteractionTag`. + */ +export const useInteractionTag = (props: InteractionTagProps, ref: React.Ref): InteractionTagState => { + 'use no memo'; + + const state: InteractionTagState = useInteractionTagBase_unstable(props, ref); + + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + state.root['data-selected'] = stringifyDataAttribute(state.selected); + + return state; +}; + +/** + * Maps the state of the InteractionTag onto the context consumed by its + * `InteractionTagPrimary` and `InteractionTagSecondary` children. The headless + * flavour omits design-only fields (appearance/shape/size) on the public state, + * but the canonical `InteractionTagContextValue` requires them - we forward + * neutral defaults so the base child hooks (which do not read these fields) + * still type-check. + */ +export const useInteractionTagContextValues = (state: InteractionTagState): InteractionTagContextValues => { + const { disabled, handleTagDismiss, handleTagSelect, interactionTagPrimaryId, selected, selectedValues, value } = + state; + + const interactionTag: InteractionTagContextValue = React.useMemo( + () => ({ + appearance: 'filled', + shape: 'rounded', + size: 'medium', + disabled, + handleTagDismiss, + handleTagSelect, + interactionTagPrimaryId, + selected, + selectedValues, + value, + }), + [disabled, handleTagDismiss, handleTagSelect, interactionTagPrimaryId, selected, selectedValues, value], + ); + + return { interactionTag }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.test.tsx new file mode 100644 index 00000000000000..01f4883decca7c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.test.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { InteractionTagPrimary } from './InteractionTagPrimary'; +import { InteractionTag } from '../InteractionTag'; + +const wrap = (ui: React.ReactNode) => render({ui}); + +describe('InteractionTagPrimary', () => { + isConformant({ + Component: InteractionTagPrimary, + displayName: 'InteractionTagPrimary', + requiredProps: { children: 'tag' }, + }); + + it('renders a button using the parent InteractionTag context id', () => { + const result = wrap(label); + const button = result.getByRole('button'); + expect(button.id).toEqual(expect.stringMatching(/fui-InteractionTagPrimary-/)); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.tsx new file mode 100644 index 00000000000000..51c3bc87543ca3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { InteractionTagPrimaryProps } from './InteractionTagPrimary.types'; +import { useInteractionTagPrimary, useInteractionTagPrimaryContextValues } from './useInteractionTagPrimary'; +import { renderInteractionTagPrimary } from './renderInteractionTagPrimary'; + +/** + * The primary, focusable action of an `InteractionTag`. + */ +export const InteractionTagPrimary: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useInteractionTagPrimary(props, ref); + const contextValues = useInteractionTagPrimaryContextValues(state); + return renderInteractionTagPrimary(state, contextValues); +}); + +InteractionTagPrimary.displayName = 'InteractionTagPrimary'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.types.ts new file mode 100644 index 00000000000000..28783bf8560fd0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/InteractionTagPrimary.types.ts @@ -0,0 +1,31 @@ +import type { + InteractionTagPrimarySlots as InteractionTagPrimaryBaseSlots, + InteractionTagPrimaryBaseProps, + InteractionTagPrimaryBaseState, + InteractionTagPrimaryContextValues as InteractionTagPrimaryBaseContextValues, +} from '@fluentui/react-tags'; + +export type InteractionTagPrimarySlots = InteractionTagPrimaryBaseSlots; + +export type InteractionTagPrimaryProps = InteractionTagPrimaryBaseProps; + +export type InteractionTagPrimaryState = InteractionTagPrimaryBaseState & { + root: { + /** + * Data attribute set when the primary action is disabled. + */ + 'data-disabled'?: string; + + /** + * Data attribute set when the interaction tag is selected. + */ + 'data-selected'?: string; + + /** + * Data attribute set when the interaction tag has a secondary action. + */ + 'data-has-secondary-action'?: string; + }; +}; + +export type InteractionTagPrimaryContextValues = InteractionTagPrimaryBaseContextValues; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/index.ts new file mode 100644 index 00000000000000..25013edbb0d6b9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/index.ts @@ -0,0 +1,9 @@ +export { InteractionTagPrimary } from './InteractionTagPrimary'; +export { renderInteractionTagPrimary } from './renderInteractionTagPrimary'; +export { useInteractionTagPrimary, useInteractionTagPrimaryContextValues } from './useInteractionTagPrimary'; +export type { + InteractionTagPrimarySlots, + InteractionTagPrimaryProps, + InteractionTagPrimaryState, + InteractionTagPrimaryContextValues, +} from './InteractionTagPrimary.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts new file mode 100644 index 00000000000000..50bb4d1717ec4e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts @@ -0,0 +1,6 @@ +import { renderInteractionTagPrimary_unstable } from '@fluentui/react-tags'; + +/** + * Renders the final JSX of the InteractionTagPrimary component. + */ +export const renderInteractionTagPrimary = renderInteractionTagPrimary_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts new file mode 100644 index 00000000000000..e9b2cf002108d4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts @@ -0,0 +1,44 @@ +'use client'; + +import * as React from 'react'; +import { useInteractionTagPrimaryBase_unstable } from '@fluentui/react-tags'; + +import type { + InteractionTagPrimaryContextValues, + InteractionTagPrimaryProps, + InteractionTagPrimaryState, +} from './InteractionTagPrimary.types'; +import { stringifyDataAttribute } from '../../utils'; + +/** + * Returns the state for an InteractionTagPrimary component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderInteractionTagPrimary`. + */ +export const useInteractionTagPrimary = ( + props: InteractionTagPrimaryProps, + ref: React.Ref, +): InteractionTagPrimaryState => { + 'use no memo'; + + const state: InteractionTagPrimaryState = useInteractionTagPrimaryBase_unstable(props, ref); + + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + state.root['data-selected'] = stringifyDataAttribute(state.selected); + state.root['data-has-secondary-action'] = stringifyDataAttribute(state.hasSecondaryAction); + + return state; +}; + +const emptyAvatarContext = { size: undefined, shape: undefined } as const; + +/** + * Returns the avatar context values consumed by the canonical InteractionTagPrimary + * renderer. The headless flavour passes no avatar size/shape - consumers style + * the media slot themselves. + */ +export const useInteractionTagPrimaryContextValues = ( + _state: InteractionTagPrimaryState, +): InteractionTagPrimaryContextValues => { + const avatar = React.useMemo(() => emptyAvatarContext, []); + return { avatar }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.test.tsx new file mode 100644 index 00000000000000..8fe0acc044d5d2 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.test.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { InteractionTagSecondary } from './InteractionTagSecondary'; +import { InteractionTag } from '../InteractionTag'; +import { InteractionTagPrimary } from '../InteractionTagPrimary'; + +const wrap = (ui: React.ReactNode) => + render( + + p + {ui} + , + ); + +describe('InteractionTagSecondary', () => { + isConformant({ + Component: InteractionTagSecondary, + displayName: 'InteractionTagSecondary', + requiredProps: { 'aria-label': 'dismiss' }, + }); + + it('does NOT render a default dismiss icon (headless)', () => { + const result = wrap(); + const sec = result.getByTestId('sec'); + expect(sec.children).toHaveLength(0); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.tsx b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.tsx new file mode 100644 index 00000000000000..58d278401b9e4f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.tsx @@ -0,0 +1,22 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { InteractionTagSecondaryProps } from './InteractionTagSecondary.types'; +import { useInteractionTagSecondary } from './useInteractionTagSecondary'; +import { renderInteractionTagSecondary } from './renderInteractionTagSecondary'; + +/** + * The dismiss / secondary action of an `InteractionTag`. Renders a button with + * `Delete`/`Backspace` keyboard handling that calls the parent group's dismiss + * handler. Does not render an icon by default - supply one via `children`. + */ +export const InteractionTagSecondary: ForwardRefComponent = React.forwardRef( + (props, ref) => { + const state = useInteractionTagSecondary(props, ref); + return renderInteractionTagSecondary(state); + }, +); + +InteractionTagSecondary.displayName = 'InteractionTagSecondary'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.types.ts new file mode 100644 index 00000000000000..5d2011846a8693 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/InteractionTagSecondary.types.ts @@ -0,0 +1,23 @@ +import type { + InteractionTagSecondarySlots as InteractionTagSecondaryBaseSlots, + InteractionTagSecondaryBaseProps, + InteractionTagSecondaryBaseState, +} from '@fluentui/react-tags'; + +export type InteractionTagSecondarySlots = InteractionTagSecondaryBaseSlots; + +export type InteractionTagSecondaryProps = InteractionTagSecondaryBaseProps; + +export type InteractionTagSecondaryState = InteractionTagSecondaryBaseState & { + root: { + /** + * Data attribute set when the secondary action is disabled. + */ + 'data-disabled'?: string; + + /** + * Data attribute set when the interaction tag is selected. + */ + 'data-selected'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/index.ts new file mode 100644 index 00000000000000..2a2db76ff39ca9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/index.ts @@ -0,0 +1,8 @@ +export { InteractionTagSecondary } from './InteractionTagSecondary'; +export { renderInteractionTagSecondary } from './renderInteractionTagSecondary'; +export { useInteractionTagSecondary } from './useInteractionTagSecondary'; +export type { + InteractionTagSecondarySlots, + InteractionTagSecondaryProps, + InteractionTagSecondaryState, +} from './InteractionTagSecondary.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts new file mode 100644 index 00000000000000..c451aa0851c1c3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts @@ -0,0 +1,6 @@ +import { renderInteractionTagSecondary_unstable } from '@fluentui/react-tags'; + +/** + * Renders the final JSX of the InteractionTagSecondary component. + */ +export const renderInteractionTagSecondary = renderInteractionTagSecondary_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts new file mode 100644 index 00000000000000..c4a1872176591d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts @@ -0,0 +1,29 @@ +'use client'; + +import type * as React from 'react'; +import { useInteractionTagSecondaryBase_unstable } from '@fluentui/react-tags'; + +import type { InteractionTagSecondaryProps, InteractionTagSecondaryState } from './InteractionTagSecondary.types'; +import { stringifyDataAttribute } from '../../utils'; + +/** + * Returns the state for an InteractionTagSecondary component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderInteractionTagSecondary`. + * + * Headless: this hook does not inject any default icon as `root.children` - the + * canonical styled hook injects `DismissRegular`. Consumers should supply their + * own icon via `children`. + */ +export const useInteractionTagSecondary = ( + props: InteractionTagSecondaryProps, + ref: React.Ref, +): InteractionTagSecondaryState => { + 'use no memo'; + + const state: InteractionTagSecondaryState = useInteractionTagSecondaryBase_unstable(props, ref); + + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + state.root['data-selected'] = stringifyDataAttribute(state.selected); + + return state; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.test.tsx new file mode 100644 index 00000000000000..a2bbc2e76554cc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { Tag } from './Tag'; + +describe('Tag', () => { + isConformant({ + Component: Tag, + displayName: 'Tag', + }); + + it('renders a default span', () => { + const result = render(Default tag); + const tag = result.getByText('Default tag'); + expect(tag.closest('span')).toBeInTheDocument(); + }); + + it('renders a button with state data attributes when dismissible', () => { + const result = render(Dismiss me); + const button = result.getByRole('button'); + expect(button).toHaveAttribute('data-dismissible'); + }); + + it('sets data-disabled when disabled', () => { + const result = render(Disabled tag); + const tag = result.getByText('Disabled tag'); + expect(tag.closest('[data-disabled]')).not.toBeNull(); + }); + + it('sets data-selected when selected', () => { + const result = render(Selected tag); + const tag = result.getByText('Selected tag'); + expect(tag.closest('[data-selected]')).not.toBeNull(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.tsx new file mode 100644 index 00000000000000..5fe3f053c3cab3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { TagProps } from './Tag.types'; +import { useTag, useTagContextValues } from './useTag'; +import { renderTag } from './renderTag'; + +/** + * A visual representation of an attribute that can be optionally dismissed. + */ +export const Tag: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTag(props, ref); + const contextValues = useTagContextValues(state); + return renderTag(state, contextValues); +}); + +Tag.displayName = 'Tag'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.types.ts new file mode 100644 index 00000000000000..4025106d84d2e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/Tag.types.ts @@ -0,0 +1,31 @@ +import type { + TagSlots as TagBaseSlots, + TagBaseProps, + TagBaseState, + TagContextValues as TagBaseContextValues, +} from '@fluentui/react-tags'; + +export type TagSlots = TagBaseSlots; + +export type TagProps = TagBaseProps; + +export type TagState = TagBaseState & { + root: { + /** + * Data attribute set when the tag is disabled. + */ + 'data-disabled'?: string; + + /** + * Data attribute set when the tag renders as a dismissible button. + */ + 'data-dismissible'?: string; + + /** + * Data attribute set when the tag is selected. + */ + 'data-selected'?: string; + }; +}; + +export type TagContextValues = TagBaseContextValues; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tag/index.ts new file mode 100644 index 00000000000000..c85428f48e3673 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/index.ts @@ -0,0 +1,4 @@ +export { Tag } from './Tag'; +export { renderTag } from './renderTag'; +export { useTag, useTagContextValues } from './useTag'; +export type { TagSlots, TagProps, TagState, TagContextValues } from './Tag.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts new file mode 100644 index 00000000000000..326dc0cd3552c1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts @@ -0,0 +1,6 @@ +import { renderTag_unstable } from '@fluentui/react-tags'; + +/** + * Renders the final JSX of the Tag component, given the state and context values. + */ +export const renderTag = renderTag_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts new file mode 100644 index 00000000000000..8602ea93b7d8a5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts @@ -0,0 +1,35 @@ +'use client'; + +import * as React from 'react'; +import { useTagBase_unstable } from '@fluentui/react-tags'; + +import type { TagContextValues, TagProps, TagState } from './Tag.types'; +import { stringifyDataAttribute } from '../../utils'; + +/** + * Returns the state for a Tag component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderTag`. + */ +export const useTag = (props: TagProps, ref: React.Ref): TagState => { + 'use no memo'; + + const state: TagState = useTagBase_unstable(props, ref); + + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + state.root['data-dismissible'] = stringifyDataAttribute(state.dismissible); + state.root['data-selected'] = stringifyDataAttribute(state.selected); + + return state; +}; + +const emptyAvatarContext = { size: undefined, shape: undefined } as const; + +/** + * Returns the avatar context values passed to the canonical Tag renderer. + * The headless flavour does not impose a size/shape on a nested Avatar - consumers + * style the avatar themselves via the `media` slot. + */ +export const useTagContextValues = (_state: TagState): TagContextValues => { + const avatar = React.useMemo(() => emptyAvatarContext, []); + return { avatar }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.test.tsx new file mode 100644 index 00000000000000..5f83bcf0788f15 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.test.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { isConformant } from '../../testing/isConformant'; +import { TagGroup } from './TagGroup'; +import { Tag } from '../Tag'; + +describe('TagGroup', () => { + isConformant({ + Component: TagGroup, + displayName: 'TagGroup', + }); + + it('should invoke onDismiss when a child dismissible Tag is clicked', () => { + const onDismiss = jest.fn(); + const { getByRole } = render( + + + tag + + , + ); + + fireEvent.click(getByRole('button')); + + expect(onDismiss).toHaveBeenCalledWith(expect.anything(), { value: '1' }); + }); + + it('sets data-disabled when disabled', () => { + const { getByRole } = render( + + tag + , + ); + expect(getByRole('toolbar')).toHaveAttribute('data-disabled'); + }); + + it('does NOT spread Tabster arrow-navigation attributes onto root (headless)', () => { + const { getByRole } = render( + + tag + , + ); + expect(getByRole('toolbar').getAttribute('data-tabster')).toBeNull(); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.tsx new file mode 100644 index 00000000000000..0859072b7d3d52 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.tsx @@ -0,0 +1,20 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { TagGroupProps } from './TagGroup.types'; +import { useTagGroup, useTagGroupContextValues } from './useTagGroup'; +import { renderTagGroup } from './renderTagGroup'; + +/** + * A container for one or more `Tag` or `InteractionTag` children that + * coordinates dismissal and selection. + */ +export const TagGroup: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTagGroup(props, ref); + const contextValues = useTagGroupContextValues(state); + return renderTagGroup(state, contextValues); +}); + +TagGroup.displayName = 'TagGroup'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.types.ts new file mode 100644 index 00000000000000..fb74c0872fc356 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/TagGroup.types.ts @@ -0,0 +1,28 @@ +import type { + TagGroupSlots as TagGroupBaseSlots, + TagGroupBaseProps, + TagGroupBaseState, + TagGroupContextValue, +} from '@fluentui/react-tags'; + +export type TagGroupSlots = TagGroupBaseSlots; + +export type TagGroupProps = TagGroupBaseProps; + +export type TagGroupState = TagGroupBaseState & { + root: { + /** + * Data attribute set when the group is disabled. + */ + 'data-disabled'?: string; + + /** + * Data attribute set when the group is dismissible. + */ + 'data-dismissible'?: string; + }; +}; + +export type TagGroupContextValues = { + tagGroup: TagGroupContextValue; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/index.ts new file mode 100644 index 00000000000000..7d7beda5cbdb45 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/index.ts @@ -0,0 +1,4 @@ +export { TagGroup } from './TagGroup'; +export { renderTagGroup } from './renderTagGroup'; +export { useTagGroup, useTagGroupContextValues } from './useTagGroup'; +export type { TagGroupSlots, TagGroupProps, TagGroupState, TagGroupContextValues } from './TagGroup.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts new file mode 100644 index 00000000000000..b93aecc67f92ab --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts @@ -0,0 +1,6 @@ +import { renderTagGroup_unstable } from '@fluentui/react-tags'; + +/** + * Renders the final JSX of the TagGroup component, given the state and context values. + */ +export const renderTagGroup = renderTagGroup_unstable; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts new file mode 100644 index 00000000000000..7f716e811d79d8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts @@ -0,0 +1,52 @@ +'use client'; + +import * as React from 'react'; +import { useTagGroupBase_unstable } from '@fluentui/react-tags'; +import type { TagGroupContextValue } from '@fluentui/react-tags'; + +import type { TagGroupContextValues, TagGroupProps, TagGroupState } from './TagGroup.types'; +import { stringifyDataAttribute } from '../../utils'; + +/** + * Returns the state for a TagGroup component, given its props and ref. + * + * Headless: the canonical `useTagGroupBase_unstable` accepts pluggable + * `arrowNavigationProps` and `onAfterTagDismiss` options for keyboard + * navigation and post-dismiss focus restoration. The headless flavour omits + * them - consumers wire those behaviours up themselves. + */ +export const useTagGroup = (props: TagGroupProps, ref: React.Ref): TagGroupState => { + 'use no memo'; + + const state: TagGroupState = useTagGroupBase_unstable(props, ref); + + state.root['data-disabled'] = stringifyDataAttribute(state.disabled); + state.root['data-dismissible'] = stringifyDataAttribute(state.dismissible); + + return state; +}; + +/** + * Maps the state of the TagGroup onto the context consumed by Tag children. + * Forwards neutral design defaults so the base Tag hooks (which do not read + * appearance/shape/size) still type-check against the canonical context value. + */ +export const useTagGroupContextValues = (state: TagGroupState): TagGroupContextValues => { + const { handleTagDismiss, handleTagSelect, selectedValues, disabled, dismissible, role } = state; + + const tagGroup: TagGroupContextValue = React.useMemo( + () => ({ + handleTagDismiss, + handleTagSelect, + selectedValues, + disabled, + dismissible, + role, + size: 'medium', + appearance: 'filled', + }), + [handleTagDismiss, handleTagSelect, selectedValues, disabled, dismissible, role], + ); + + return { tagGroup }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/interaction-tag-primary.ts b/packages/react-components/react-headless-components-preview/library/src/interaction-tag-primary.ts new file mode 100644 index 00000000000000..de3d386f990a17 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/interaction-tag-primary.ts @@ -0,0 +1,12 @@ +export { + InteractionTagPrimary, + renderInteractionTagPrimary, + useInteractionTagPrimary, + useInteractionTagPrimaryContextValues, +} from './components/InteractionTagPrimary'; +export type { + InteractionTagPrimarySlots, + InteractionTagPrimaryProps, + InteractionTagPrimaryState, + InteractionTagPrimaryContextValues, +} from './components/InteractionTagPrimary'; diff --git a/packages/react-components/react-headless-components-preview/library/src/interaction-tag-secondary.ts b/packages/react-components/react-headless-components-preview/library/src/interaction-tag-secondary.ts new file mode 100644 index 00000000000000..40ead6f0f7987e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/interaction-tag-secondary.ts @@ -0,0 +1,10 @@ +export { + InteractionTagSecondary, + renderInteractionTagSecondary, + useInteractionTagSecondary, +} from './components/InteractionTagSecondary'; +export type { + InteractionTagSecondarySlots, + InteractionTagSecondaryProps, + InteractionTagSecondaryState, +} from './components/InteractionTagSecondary'; diff --git a/packages/react-components/react-headless-components-preview/library/src/interaction-tag.ts b/packages/react-components/react-headless-components-preview/library/src/interaction-tag.ts new file mode 100644 index 00000000000000..ce83a285a9cbbc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/interaction-tag.ts @@ -0,0 +1,12 @@ +export { + InteractionTag, + renderInteractionTag, + useInteractionTag, + useInteractionTagContextValues, +} from './components/InteractionTag'; +export type { + InteractionTagSlots, + InteractionTagProps, + InteractionTagState, + InteractionTagContextValues, +} from './components/InteractionTag'; diff --git a/packages/react-components/react-headless-components-preview/library/src/tag-group.ts b/packages/react-components/react-headless-components-preview/library/src/tag-group.ts new file mode 100644 index 00000000000000..1e326625ed5c5e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/tag-group.ts @@ -0,0 +1,2 @@ +export { TagGroup, renderTagGroup, useTagGroup, useTagGroupContextValues } from './components/TagGroup'; +export type { TagGroupSlots, TagGroupProps, TagGroupState, TagGroupContextValues } from './components/TagGroup'; diff --git a/packages/react-components/react-headless-components-preview/library/src/tag.ts b/packages/react-components/react-headless-components-preview/library/src/tag.ts new file mode 100644 index 00000000000000..63e8f9793ddc8d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/tag.ts @@ -0,0 +1,2 @@ +export { Tag, renderTag, useTag, useTagContextValues } from './components/Tag'; +export type { TagSlots, TagProps, TagState, TagContextValues } from './components/Tag'; From d0ba2203ae0afa717f2713aedd202342a263d6af Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 14:44:33 +0200 Subject: [PATCH 2/9] chore: change file for headless Tag migration --- ...nents-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json diff --git a/change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json b/change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json new file mode 100644 index 00000000000000..7f1fc996242367 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add headless Tag, InteractionTag, InteractionTagPrimary, InteractionTagSecondary, TagGroup. Available as ./tag, ./interaction-tag, ./interaction-tag-primary, ./interaction-tag-secondary, ./tag-group subpath exports. Delegates to @fluentui/react-tags base hooks; no icons, styles, Tabster, or floating-ui.", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} From 67a24ed4917cdfb05a9f1e15ca985104ae69e91c Mon Sep 17 00:00:00 2001 From: mainframev Date: Tue, 19 May 2026 14:54:30 +0200 Subject: [PATCH 3/9] feat(react-headless-components-preview): add Tag, InteractionTag, TagGroup stories --- .../InteractionTagDefault.stories.tsx | 35 +++++++++ .../InteractionTagDescription.md | 3 + .../src/InteractionTag/index.stories.tsx | 16 ++++ .../InteractionTag/interactionTag.module.css | 68 ++++++++++++++++ .../stories/src/Tag/TagDefault.stories.tsx | 43 +++++++++++ .../stories/src/Tag/TagDescription.md | 3 + .../stories/src/Tag/index.stories.tsx | 16 ++++ .../stories/src/Tag/tag.module.css | 77 +++++++++++++++++++ .../src/TagGroup/TagGroupDefault.stories.tsx | 47 +++++++++++ .../src/TagGroup/TagGroupDescription.md | 3 + .../stories/src/TagGroup/index.stories.tsx | 16 ++++ .../stories/src/TagGroup/tagGroup.module.css | 50 ++++++++++++ 12 files changed, 377 insertions(+) create mode 100644 packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/InteractionTag/index.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/InteractionTag/interactionTag.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tag/TagDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tag/TagDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tag/index.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tag/tag.module.css create mode 100644 packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDefault.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDescription.md create mode 100644 packages/react-components/react-headless-components-preview/stories/src/TagGroup/index.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/TagGroup/tagGroup.module.css diff --git a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDefault.stories.tsx new file mode 100644 index 00000000000000..afa3386173c75a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDefault.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import { InteractionTagSecondary } from '@fluentui/react-headless-components-preview/interaction-tag-secondary'; +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './interactionTag.module.css'; + +const initial = [ + { value: '1', label: 'Project Alpha' }, + { value: '2', label: 'Project Beta' }, +]; + +export const Default = (): React.ReactNode => { + const [tags, setTags] = React.useState(initial); + + return ( + setTags(prev => prev.filter(t => t.value !== data.value))} + > +
+ {tags.map(t => ( + + {t.label} + + + + + ))} +
+
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDescription.md b/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDescription.md new file mode 100644 index 00000000000000..1193955b6c6a33 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDescription.md @@ -0,0 +1,3 @@ +An InteractionTag pairs a focusable primary action with an optional secondary (dismiss) action. + +The headless `InteractionTagSecondary` does NOT inject a default icon - pass `children` to render whichever icon fits the design. diff --git a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/index.stories.tsx new file mode 100644 index 00000000000000..81b4d1f7096a55 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/index.stories.tsx @@ -0,0 +1,16 @@ +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; + +import descriptionMd from './InteractionTagDescription.md'; +export { Default } from './InteractionTagDefault.stories'; + +export default { + title: 'Components/InteractionTag', + component: InteractionTag, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/interactionTag.module.css b/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/interactionTag.module.css new file mode 100644 index 00000000000000..86e3fcb996f9e8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/interactionTag.module.css @@ -0,0 +1,68 @@ +.interactionTag { + display: inline-flex; + align-items: stretch; + border-radius: var(--radius-pill); + overflow: hidden; + border: 1px solid var(--border-strong); + background: var(--surface); + font-size: 12px; + height: 28px; +} + +.interactionTag[data-selected] { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); +} + +.primary { + appearance: none; + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 0 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.primary:hover { + background: var(--surface-muted); +} + +.primary:focus-visible, +.secondary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev) inset, 0 0 0 4px var(--accent) inset; +} + +.primary[data-disabled] { + cursor: not-allowed; + opacity: 0.5; +} + +.secondary { + appearance: none; + border: none; + background: transparent; + color: inherit; + padding: 0 10px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-left: 1px solid var(--border); +} + +.secondary:hover { + background: var(--surface-muted); +} + +.demo { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tag/TagDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tag/TagDefault.stories.tsx new file mode 100644 index 00000000000000..f0f29f082a6d49 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tag/TagDefault.stories.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tag.module.css'; + +export const Default = (): React.ReactNode => ( +
+
+ Default + + Selected + + + Disabled + +
+ +
+ , + }} + > + Dismissible + + , + }} + > + Disabled & dismissible + +
+
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tag/TagDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Tag/TagDescription.md new file mode 100644 index 00000000000000..1dbdae15a170d9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tag/TagDescription.md @@ -0,0 +1,3 @@ +A Tag is a compact visual representation of an attribute, label, or filter that can optionally be dismissed. + +The headless `Tag` exposes `data-disabled`, `data-dismissible`, and `data-selected` attributes on its root so consumers can style each state without overriding any built-in styles. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tag/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tag/index.stories.tsx new file mode 100644 index 00000000000000..612201f4189a79 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tag/index.stories.tsx @@ -0,0 +1,16 @@ +import { Tag } from '@fluentui/react-headless-components-preview/tag'; + +import descriptionMd from './TagDescription.md'; +export { Default } from './TagDefault.stories'; + +export default { + title: 'Components/Tag', + component: Tag, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tag/tag.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tag/tag.module.css new file mode 100644 index 00000000000000..07528538f0a773 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tag/tag.module.css @@ -0,0 +1,77 @@ +/* tag — outlined pill with selectable + dismissible states */ +.tag { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 10px; + border-radius: var(--radius-pill); + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + cursor: default; + user-select: none; + transition: background-color var(--duration-fast) var(--ease-standard), + color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); +} + +.tag[data-dismissible] { + cursor: pointer; + border: none; + padding: 0 0 0 10px; + background: var(--surface-muted); +} + +.tag[data-dismissible]:hover { + background: var(--surface-sunken); +} + +.tag[data-dismissible]:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.tag[data-selected] { + background: var(--accent); + color: var(--accent-contrast); + border-color: var(--accent); +} + +.tag[data-disabled] { + opacity: 0.4; + cursor: not-allowed; +} + +.tag[data-disabled][data-dismissible]:hover { + background: var(--surface-muted); +} + +.dismissIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-left: 2px; + border-radius: 50%; +} + +.tag[data-dismissible]:hover .dismissIcon { + background: var(--surface); +} + +.demo { + display: flex; + flex-direction: column; + gap: 16px; +} + +.demoRow { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDefault.stories.tsx new file mode 100644 index 00000000000000..c6f05656dc2f86 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDefault.stories.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tagGroup.module.css'; + +const initialTags = [ + { value: '1', label: 'Tag one' }, + { value: '2', label: 'Tag two' }, + { value: '3', label: 'Tag three' }, +]; + +export const Default = (): React.ReactNode => { + const [tags, setTags] = React.useState(initialTags); + + return ( +
+ setTags(prev => prev.filter(t => t.value !== data.value))} + > + {tags.map(t => ( + }} + > + {t.label} + + ))} + + + + }}> + Locked + + }}> + Read-only + + +
+ ); +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDescription.md b/packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDescription.md new file mode 100644 index 00000000000000..d6f970cd407cb5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDescription.md @@ -0,0 +1,3 @@ +A TagGroup is a container that coordinates dismissal and selection across one or more `Tag` children. + +The headless `TagGroup` ships without keyboard navigation - it does not include Tabster's arrow-key group or post-dismiss focus restoration. Consumers wire those behaviours up themselves with a focus-management strategy that fits their application (Tabster, a virtual focus manager, etc.). diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/TagGroup/index.stories.tsx new file mode 100644 index 00000000000000..7f6561f02acd85 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagGroup/index.stories.tsx @@ -0,0 +1,16 @@ +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; + +import descriptionMd from './TagGroupDescription.md'; +export { Default } from './TagGroupDefault.stories'; + +export default { + title: 'Components/TagGroup', + component: TagGroup, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/tagGroup.module.css b/packages/react-components/react-headless-components-preview/stories/src/TagGroup/tagGroup.module.css new file mode 100644 index 00000000000000..178799fb90cb83 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/TagGroup/tagGroup.module.css @@ -0,0 +1,50 @@ +.group { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px; + border-radius: var(--radius-md); + background: var(--surface); + border: 1px solid var(--border); +} + +.group[data-disabled] { + opacity: 0.55; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0 0 0 10px; + border-radius: var(--radius-pill); + background: var(--surface-muted); + color: var(--text); + font-size: 12px; + cursor: pointer; + border: none; +} + +.tag:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); +} + +.tag[data-disabled] { + cursor: not-allowed; +} + +.dismissIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; +} + +.demo { + display: flex; + flex-direction: column; + gap: 16px; +} From 9aeaa431d78f883da1a5af1b887796ac6e967806 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 20 May 2026 00:00:12 +0200 Subject: [PATCH 4/9] refactor(react-headless-components-preview): consolidate Tag stories under Tags --- ...-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json | 2 +- .../library/etc/interaction-tag-primary.api.md | 9 ++++----- .../library/etc/interaction-tag-secondary.api.md | 7 +++---- .../library/etc/interaction-tag.api.md | 11 ++++------- .../library/etc/tag-group.api.md | 10 ++++------ .../library/etc/tag.api.md | 9 ++++----- .../components/InteractionTag/renderInteractionTag.ts | 7 +------ .../renderInteractionTagPrimary.ts | 7 +------ .../InteractionTagPrimary/useInteractionTagPrimary.ts | 5 ----- .../renderInteractionTagSecondary.ts | 7 +------ .../library/src/components/Tag/renderTag.ts | 7 +------ .../library/src/components/TagGroup/renderTagGroup.ts | 7 +------ .../library/src/components/TagGroup/useTagGroup.ts | 10 ---------- .../InteractionTag/InteractionTagDefault.stories.tsx | 0 .../InteractionTag/InteractionTagDescription.md | 0 .../src/{ => Tags}/InteractionTag/index.stories.tsx | 2 +- .../InteractionTag/interactionTag.module.css | 0 .../stories/src/{ => Tags}/Tag/TagDefault.stories.tsx | 0 .../stories/src/{ => Tags}/Tag/TagDescription.md | 0 .../stories/src/{ => Tags}/Tag/index.stories.tsx | 2 +- .../stories/src/{ => Tags}/Tag/tag.module.css | 0 .../{ => Tags}/TagGroup/TagGroupDefault.stories.tsx | 0 .../src/{ => Tags}/TagGroup/TagGroupDescription.md | 0 .../stories/src/{ => Tags}/TagGroup/index.stories.tsx | 2 +- .../src/{ => Tags}/TagGroup/tagGroup.module.css | 0 25 files changed, 28 insertions(+), 76 deletions(-) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/InteractionTag/InteractionTagDefault.stories.tsx (100%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/InteractionTag/InteractionTagDescription.md (100%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/InteractionTag/index.stories.tsx (89%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/InteractionTag/interactionTag.module.css (100%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/Tag/TagDefault.stories.tsx (100%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/Tag/TagDescription.md (100%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/Tag/index.stories.tsx (90%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/Tag/tag.module.css (100%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/TagGroup/TagGroupDefault.stories.tsx (100%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/TagGroup/TagGroupDescription.md (100%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/TagGroup/index.stories.tsx (90%) rename packages/react-components/react-headless-components-preview/stories/src/{ => Tags}/TagGroup/tagGroup.module.css (100%) diff --git a/change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json b/change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json index 7f1fc996242367..ee5985661be4bb 100644 --- a/change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json +++ b/change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json @@ -1,6 +1,6 @@ { "type": "minor", - "comment": "Add headless Tag, InteractionTag, InteractionTagPrimary, InteractionTagSecondary, TagGroup. Available as ./tag, ./interaction-tag, ./interaction-tag-primary, ./interaction-tag-secondary, ./tag-group subpath exports. Delegates to @fluentui/react-tags base hooks; no icons, styles, Tabster, or floating-ui.", + "comment": "feat: add headless Tags components", "packageName": "@fluentui/react-headless-components-preview", "email": "vgenaev@gmail.com", "dependentChangeType": "patch" diff --git a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md index 29546a5a5740e8..ec6049905c719f 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md @@ -6,11 +6,11 @@ import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { InteractionTagPrimaryBaseProps } from '@fluentui/react-tags'; -import { InteractionTagPrimaryBaseState } from '@fluentui/react-tags'; -import { InteractionTagPrimaryContextValues as InteractionTagPrimaryContextValues_2 } from '@fluentui/react-tags'; +import type { InteractionTagPrimaryBaseState } from '@fluentui/react-tags'; +import type { InteractionTagPrimaryContextValues as InteractionTagPrimaryContextValues_2 } from '@fluentui/react-tags'; import type { InteractionTagPrimarySlots as InteractionTagPrimarySlots_2 } from '@fluentui/react-tags'; -import { JSXElement } from '@fluentui/react-utilities'; import * as React_2 from 'react'; +import { renderInteractionTagPrimary_unstable as renderInteractionTagPrimary } from '@fluentui/react-tags'; // @public export const InteractionTagPrimary: ForwardRefComponent; @@ -33,8 +33,7 @@ export type InteractionTagPrimaryState = InteractionTagPrimaryBaseState & { }; }; -// @public -export const renderInteractionTagPrimary: (state: InteractionTagPrimaryBaseState, contextValues: InteractionTagPrimaryContextValues_2) => JSXElement; +export { renderInteractionTagPrimary } // @public export const useInteractionTagPrimary: (props: InteractionTagPrimaryProps, ref: React_2.Ref) => InteractionTagPrimaryState; diff --git a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md index e14e5d2b65f938..318156d797e6bf 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md @@ -6,10 +6,10 @@ import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { InteractionTagSecondaryBaseProps } from '@fluentui/react-tags'; -import { InteractionTagSecondaryBaseState } from '@fluentui/react-tags'; +import type { InteractionTagSecondaryBaseState } from '@fluentui/react-tags'; import type { InteractionTagSecondarySlots as InteractionTagSecondarySlots_2 } from '@fluentui/react-tags'; -import { JSXElement } from '@fluentui/react-utilities'; import type * as React_2 from 'react'; +import { renderInteractionTagSecondary_unstable as renderInteractionTagSecondary } from '@fluentui/react-tags'; // @public export const InteractionTagSecondary: ForwardRefComponent; @@ -28,8 +28,7 @@ export type InteractionTagSecondaryState = InteractionTagSecondaryBaseState & { }; }; -// @public -export const renderInteractionTagSecondary: (state: InteractionTagSecondaryBaseState) => JSXElement; +export { renderInteractionTagSecondary } // @public export const useInteractionTagSecondary: (props: InteractionTagSecondaryProps, ref: React_2.Ref) => InteractionTagSecondaryState; diff --git a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md index fefa3de53ec1e8..327cb3ab86fb53 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md @@ -6,11 +6,11 @@ import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { InteractionTagBaseProps } from '@fluentui/react-tags'; -import { InteractionTagBaseState } from '@fluentui/react-tags'; -import { InteractionTagContextValue } from '@fluentui/react-tags'; +import type { InteractionTagBaseState } from '@fluentui/react-tags'; +import type { InteractionTagContextValue } from '@fluentui/react-tags'; import type { InteractionTagSlots as InteractionTagSlots_2 } from '@fluentui/react-tags'; -import { JSXElement } from '@fluentui/react-utilities'; import * as React_2 from 'react'; +import { renderInteractionTag_unstable as renderInteractionTag } from '@fluentui/react-tags'; // @public export const InteractionTag: ForwardRefComponent; @@ -34,10 +34,7 @@ export type InteractionTagState = InteractionTagBaseState & { }; }; -// @public -export const renderInteractionTag: (state: InteractionTagBaseState, contextValues: { - interactionTag: InteractionTagContextValue; -}) => JSXElement; +export { renderInteractionTag } // @public export const useInteractionTag: (props: InteractionTagProps, ref: React_2.Ref) => InteractionTagState; diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md index 43fee1ba553336..5e98d71a59af2e 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md @@ -5,16 +5,14 @@ ```ts import type { ForwardRefComponent } from '@fluentui/react-utilities'; -import { JSXElement } from '@fluentui/react-utilities'; import * as React_2 from 'react'; +import { renderTagGroup_unstable as renderTagGroup } from '@fluentui/react-tags'; import type { TagGroupBaseProps } from '@fluentui/react-tags'; -import { TagGroupBaseState } from '@fluentui/react-tags'; +import type { TagGroupBaseState } from '@fluentui/react-tags'; import type { TagGroupContextValue } from '@fluentui/react-tags'; -import { TagGroupContextValues as TagGroupContextValues_2 } from '@fluentui/react-tags'; import type { TagGroupSlots as TagGroupSlots_2 } from '@fluentui/react-tags'; -// @public -export const renderTagGroup: (state: TagGroupBaseState, contextValue: TagGroupContextValues_2) => JSXElement; +export { renderTagGroup } // @public export const TagGroup: ForwardRefComponent; @@ -41,7 +39,7 @@ export type TagGroupState = TagGroupBaseState & { // @public export const useTagGroup: (props: TagGroupProps, ref: React_2.Ref) => TagGroupState; -// @public +// @public (undocumented) export const useTagGroupContextValues: (state: TagGroupState) => TagGroupContextValues; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag.api.md index fb89aea49539cc..30268a5dd15fce 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/tag.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/tag.api.md @@ -5,15 +5,14 @@ ```ts import type { ForwardRefComponent } from '@fluentui/react-utilities'; -import { JSXElement } from '@fluentui/react-utilities'; import * as React_2 from 'react'; +import { renderTag_unstable as renderTag } from '@fluentui/react-tags'; import type { TagBaseProps } from '@fluentui/react-tags'; -import { TagBaseState } from '@fluentui/react-tags'; -import { TagContextValues as TagContextValues_2 } from '@fluentui/react-tags'; +import type { TagBaseState } from '@fluentui/react-tags'; +import type { TagContextValues as TagContextValues_2 } from '@fluentui/react-tags'; import type { TagSlots as TagSlots_2 } from '@fluentui/react-tags'; -// @public -export const renderTag: (state: TagBaseState, contextValues: TagContextValues_2) => JSXElement; +export { renderTag } // @public export const Tag: ForwardRefComponent; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts index 2bdfeebbd7ecab..514b8de2a53c34 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts @@ -1,6 +1 @@ -import { renderInteractionTag_unstable } from '@fluentui/react-tags'; - -/** - * Renders the final JSX of the InteractionTag component, given the state and context values. - */ -export const renderInteractionTag = renderInteractionTag_unstable; +export { renderInteractionTag_unstable as renderInteractionTag } from '@fluentui/react-tags'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts index 50bb4d1717ec4e..fbc6a6d8fc5b94 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts @@ -1,6 +1 @@ -import { renderInteractionTagPrimary_unstable } from '@fluentui/react-tags'; - -/** - * Renders the final JSX of the InteractionTagPrimary component. - */ -export const renderInteractionTagPrimary = renderInteractionTagPrimary_unstable; +export { renderInteractionTagPrimary_unstable as renderInteractionTagPrimary } from '@fluentui/react-tags'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts index e9b2cf002108d4..9676debbbc8a0a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts @@ -31,11 +31,6 @@ export const useInteractionTagPrimary = ( const emptyAvatarContext = { size: undefined, shape: undefined } as const; -/** - * Returns the avatar context values consumed by the canonical InteractionTagPrimary - * renderer. The headless flavour passes no avatar size/shape - consumers style - * the media slot themselves. - */ export const useInteractionTagPrimaryContextValues = ( _state: InteractionTagPrimaryState, ): InteractionTagPrimaryContextValues => { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts index c451aa0851c1c3..f03f0851dd0b3d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts @@ -1,6 +1 @@ -import { renderInteractionTagSecondary_unstable } from '@fluentui/react-tags'; - -/** - * Renders the final JSX of the InteractionTagSecondary component. - */ -export const renderInteractionTagSecondary = renderInteractionTagSecondary_unstable; +export { renderInteractionTagSecondary_unstable as renderInteractionTagSecondary } from '@fluentui/react-tags'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts index 326dc0cd3552c1..f13ab6789640b8 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts @@ -1,6 +1 @@ -import { renderTag_unstable } from '@fluentui/react-tags'; - -/** - * Renders the final JSX of the Tag component, given the state and context values. - */ -export const renderTag = renderTag_unstable; +export { renderTag_unstable as renderTag } from '@fluentui/react-tags'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts index b93aecc67f92ab..3982cd6cee57dc 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts @@ -1,6 +1 @@ -import { renderTagGroup_unstable } from '@fluentui/react-tags'; - -/** - * Renders the final JSX of the TagGroup component, given the state and context values. - */ -export const renderTagGroup = renderTagGroup_unstable; +export { renderTagGroup_unstable as renderTagGroup } from '@fluentui/react-tags'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts index 7f716e811d79d8..a106d1b16b6395 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts @@ -9,11 +9,6 @@ import { stringifyDataAttribute } from '../../utils'; /** * Returns the state for a TagGroup component, given its props and ref. - * - * Headless: the canonical `useTagGroupBase_unstable` accepts pluggable - * `arrowNavigationProps` and `onAfterTagDismiss` options for keyboard - * navigation and post-dismiss focus restoration. The headless flavour omits - * them - consumers wire those behaviours up themselves. */ export const useTagGroup = (props: TagGroupProps, ref: React.Ref): TagGroupState => { 'use no memo'; @@ -26,11 +21,6 @@ export const useTagGroup = (props: TagGroupProps, ref: React.Ref return state; }; -/** - * Maps the state of the TagGroup onto the context consumed by Tag children. - * Forwards neutral design defaults so the base Tag hooks (which do not read - * appearance/shape/size) still type-check against the canonical context value. - */ export const useTagGroupContextValues = (state: TagGroupState): TagGroupContextValues => { const { handleTagDismiss, handleTagSelect, selectedValues, disabled, dismissible, role } = state; diff --git a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDefault.stories.tsx similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDefault.stories.tsx rename to packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDefault.stories.tsx diff --git a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDescription.md similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/InteractionTag/InteractionTagDescription.md rename to packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDescription.md diff --git a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/index.stories.tsx similarity index 89% rename from packages/react-components/react-headless-components-preview/stories/src/InteractionTag/index.stories.tsx rename to packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/index.stories.tsx index 81b4d1f7096a55..50ab0789c7e931 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/index.stories.tsx @@ -4,7 +4,7 @@ import descriptionMd from './InteractionTagDescription.md'; export { Default } from './InteractionTagDefault.stories'; export default { - title: 'Components/InteractionTag', + title: 'Components/Tags/InteractionTag', component: InteractionTag, parameters: { docs: { diff --git a/packages/react-components/react-headless-components-preview/stories/src/InteractionTag/interactionTag.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/InteractionTag/interactionTag.module.css rename to packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tag/TagDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDefault.stories.tsx similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/Tag/TagDefault.stories.tsx rename to packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDefault.stories.tsx diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tag/TagDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDescription.md similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/Tag/TagDescription.md rename to packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDescription.md diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tag/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/index.stories.tsx similarity index 90% rename from packages/react-components/react-headless-components-preview/stories/src/Tag/index.stories.tsx rename to packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/index.stories.tsx index 612201f4189a79..56233261cf8eff 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tag/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/index.stories.tsx @@ -4,7 +4,7 @@ import descriptionMd from './TagDescription.md'; export { Default } from './TagDefault.stories'; export default { - title: 'Components/Tag', + title: 'Components/Tags/Tag', component: Tag, parameters: { docs: { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tag/tag.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/Tag/tag.module.css rename to packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDefault.stories.tsx similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDefault.stories.tsx rename to packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDefault.stories.tsx diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDescription.md similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/TagGroup/TagGroupDescription.md rename to packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDescription.md diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx similarity index 90% rename from packages/react-components/react-headless-components-preview/stories/src/TagGroup/index.stories.tsx rename to packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx index 7f6561f02acd85..177d0d29891aef 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx @@ -4,7 +4,7 @@ import descriptionMd from './TagGroupDescription.md'; export { Default } from './TagGroupDefault.stories'; export default { - title: 'Components/TagGroup', + title: 'Components/Tags/TagGroup', component: TagGroup, parameters: { docs: { diff --git a/packages/react-components/react-headless-components-preview/stories/src/TagGroup/tagGroup.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css similarity index 100% rename from packages/react-components/react-headless-components-preview/stories/src/TagGroup/tagGroup.module.css rename to packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css From d628dc61fbca9633ff0ad3653ff0f64e426a2ea8 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 20 May 2026 00:17:06 +0200 Subject: [PATCH 5/9] chore(react-headless-components-preview): drop 'use no memo' directive from Tag-family hooks React Compiler config in the fluentui repo has been updated so the opt-out directive is no longer needed for hooks that mutate their returned state (e.g. setting data-* attributes on state.root). Removes the directive from useTag, useInteractionTag, useInteractionTagPrimary, useInteractionTagSecondary, and useTagGroup. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/InteractionTag/useInteractionTag.ts | 10 ---------- .../InteractionTagPrimary/useInteractionTagPrimary.ts | 2 -- .../useInteractionTagSecondary.ts | 6 ------ .../library/src/components/Tag/useTag.ts | 7 ------- .../library/src/components/TagGroup/useTagGroup.ts | 2 -- 5 files changed, 27 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts index 34610bc7ba119d..188c54a667fbe9 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts @@ -12,8 +12,6 @@ import { stringifyDataAttribute } from '../../utils'; * The returned state can be modified with hooks before being passed to `renderInteractionTag`. */ export const useInteractionTag = (props: InteractionTagProps, ref: React.Ref): InteractionTagState => { - 'use no memo'; - const state: InteractionTagState = useInteractionTagBase_unstable(props, ref); state.root['data-disabled'] = stringifyDataAttribute(state.disabled); @@ -22,14 +20,6 @@ export const useInteractionTag = (props: InteractionTagProps, ref: React.Ref { const { disabled, handleTagDismiss, handleTagSelect, interactionTagPrimaryId, selected, selectedValues, value } = state; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts index 9676debbbc8a0a..f4d75c54956c39 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts @@ -18,8 +18,6 @@ export const useInteractionTagPrimary = ( props: InteractionTagPrimaryProps, ref: React.Ref, ): InteractionTagPrimaryState => { - 'use no memo'; - const state: InteractionTagPrimaryState = useInteractionTagPrimaryBase_unstable(props, ref); state.root['data-disabled'] = stringifyDataAttribute(state.disabled); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts index c4a1872176591d..837b6dc23ed482 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts @@ -9,17 +9,11 @@ import { stringifyDataAttribute } from '../../utils'; /** * Returns the state for an InteractionTagSecondary component, given its props and ref. * The returned state can be modified with hooks before being passed to `renderInteractionTagSecondary`. - * - * Headless: this hook does not inject any default icon as `root.children` - the - * canonical styled hook injects `DismissRegular`. Consumers should supply their - * own icon via `children`. */ export const useInteractionTagSecondary = ( props: InteractionTagSecondaryProps, ref: React.Ref, ): InteractionTagSecondaryState => { - 'use no memo'; - const state: InteractionTagSecondaryState = useInteractionTagSecondaryBase_unstable(props, ref); state.root['data-disabled'] = stringifyDataAttribute(state.disabled); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts index 8602ea93b7d8a5..c48792c094f5eb 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts @@ -11,8 +11,6 @@ import { stringifyDataAttribute } from '../../utils'; * The returned state can be modified with hooks before being passed to `renderTag`. */ export const useTag = (props: TagProps, ref: React.Ref): TagState => { - 'use no memo'; - const state: TagState = useTagBase_unstable(props, ref); state.root['data-disabled'] = stringifyDataAttribute(state.disabled); @@ -24,11 +22,6 @@ export const useTag = (props: TagProps, ref: React.Ref { const avatar = React.useMemo(() => emptyAvatarContext, []); return { avatar }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts index a106d1b16b6395..328cd9fc3ab744 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts @@ -11,8 +11,6 @@ import { stringifyDataAttribute } from '../../utils'; * Returns the state for a TagGroup component, given its props and ref. */ export const useTagGroup = (props: TagGroupProps, ref: React.Ref): TagGroupState => { - 'use no memo'; - const state: TagGroupState = useTagGroupBase_unstable(props, ref); state.root['data-disabled'] = stringifyDataAttribute(state.disabled); From a15e935399fa660c5f850e410aaa4a321bc090c5 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 20 May 2026 01:06:23 +0200 Subject: [PATCH 6/9] fix(react-headless-components-preview): suppress react-hooks/immutability for data-attribute mutations --- .../library/src/components/InteractionTag/useInteractionTag.ts | 2 ++ .../InteractionTagPrimary/useInteractionTagPrimary.ts | 2 ++ .../InteractionTagSecondary/useInteractionTagSecondary.ts | 2 ++ .../library/src/components/Tag/useTag.ts | 2 ++ .../library/src/components/TagGroup/useTagGroup.ts | 2 ++ 5 files changed, 10 insertions(+) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts index 188c54a667fbe9..5584582875e113 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts @@ -14,8 +14,10 @@ import { stringifyDataAttribute } from '../../utils'; export const useInteractionTag = (props: InteractionTagProps, ref: React.Ref): InteractionTagState => { const state: InteractionTagState = useInteractionTagBase_unstable(props, ref); + /* eslint-disable react-hooks/immutability -- intentional: decorate base state with data-* attrs for styling */ state.root['data-disabled'] = stringifyDataAttribute(state.disabled); state.root['data-selected'] = stringifyDataAttribute(state.selected); + /* eslint-enable react-hooks/immutability */ return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts index f4d75c54956c39..ab1e5f057e6ad3 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts @@ -20,9 +20,11 @@ export const useInteractionTagPrimary = ( ): InteractionTagPrimaryState => { const state: InteractionTagPrimaryState = useInteractionTagPrimaryBase_unstable(props, ref); + /* eslint-disable react-hooks/immutability -- intentional: decorate base state with data-* attrs for styling */ state.root['data-disabled'] = stringifyDataAttribute(state.disabled); state.root['data-selected'] = stringifyDataAttribute(state.selected); state.root['data-has-secondary-action'] = stringifyDataAttribute(state.hasSecondaryAction); + /* eslint-enable react-hooks/immutability */ return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts index 837b6dc23ed482..7d8154e54a6806 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts @@ -16,8 +16,10 @@ export const useInteractionTagSecondary = ( ): InteractionTagSecondaryState => { const state: InteractionTagSecondaryState = useInteractionTagSecondaryBase_unstable(props, ref); + /* eslint-disable react-hooks/immutability -- intentional: decorate base state with data-* attrs for styling */ state.root['data-disabled'] = stringifyDataAttribute(state.disabled); state.root['data-selected'] = stringifyDataAttribute(state.selected); + /* eslint-enable react-hooks/immutability */ return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts index c48792c094f5eb..21a8ce1c94c25d 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts @@ -13,9 +13,11 @@ import { stringifyDataAttribute } from '../../utils'; export const useTag = (props: TagProps, ref: React.Ref): TagState => { const state: TagState = useTagBase_unstable(props, ref); + /* eslint-disable react-hooks/immutability -- intentional: decorate base state with data-* attrs for styling */ state.root['data-disabled'] = stringifyDataAttribute(state.disabled); state.root['data-dismissible'] = stringifyDataAttribute(state.dismissible); state.root['data-selected'] = stringifyDataAttribute(state.selected); + /* eslint-enable react-hooks/immutability */ return state; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts index 328cd9fc3ab744..30b8f6f134a499 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts @@ -13,8 +13,10 @@ import { stringifyDataAttribute } from '../../utils'; export const useTagGroup = (props: TagGroupProps, ref: React.Ref): TagGroupState => { const state: TagGroupState = useTagGroupBase_unstable(props, ref); + /* eslint-disable react-hooks/immutability -- intentional: decorate base state with data-* attrs for styling */ state.root['data-disabled'] = stringifyDataAttribute(state.disabled); state.root['data-dismissible'] = stringifyDataAttribute(state.dismissible); + /* eslint-enable react-hooks/immutability */ return state; }; From c283ad216defcad9f6717aad07811fa1b24e268e Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 20 May 2026 01:34:50 +0200 Subject: [PATCH 7/9] build(react-headless-components-preview): regenerate api.md after JSDoc edits --- .../library/etc/interaction-tag-primary.api.md | 2 +- .../library/etc/interaction-tag.api.md | 2 +- .../react-headless-components-preview/library/etc/tag.api.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md index ec6049905c719f..3ecb2152a29376 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md @@ -38,7 +38,7 @@ export { renderInteractionTagPrimary } // @public export const useInteractionTagPrimary: (props: InteractionTagPrimaryProps, ref: React_2.Ref) => InteractionTagPrimaryState; -// @public +// @public (undocumented) export const useInteractionTagPrimaryContextValues: (_state: InteractionTagPrimaryState) => InteractionTagPrimaryContextValues; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md index 327cb3ab86fb53..6ae31ecfbfbd76 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag.api.md @@ -39,7 +39,7 @@ export { renderInteractionTag } // @public export const useInteractionTag: (props: InteractionTagProps, ref: React_2.Ref) => InteractionTagState; -// @public +// @public (undocumented) export const useInteractionTagContextValues: (state: InteractionTagState) => InteractionTagContextValues; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-headless-components-preview/library/etc/tag.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag.api.md index 30268a5dd15fce..218f4cd57791a3 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/tag.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/tag.api.md @@ -38,7 +38,7 @@ export type TagState = TagBaseState & { // @public export const useTag: (props: TagProps, ref: React_2.Ref) => TagState; -// @public +// @public (undocumented) export const useTagContextValues: (_state: TagState) => TagContextValues; // (No @packageDocumentation comment for this package) From 7689fa412e67bc740eabe4e427493809134d8abb Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 20 May 2026 01:47:30 +0200 Subject: [PATCH 8/9] feat(react-headless-components-preview): split Tag-family stories into v9-style example pages --- .../InteractionTagDefault.stories.tsx | 34 +---- .../InteractionTagDisabled.stories.tsx | 33 +++++ .../InteractionTagDismiss.stories.tsx | 82 +++++++++++ ...InteractionTagHasPrimaryAction.stories.tsx | 41 ++++++ .../InteractionTagIcon.stories.tsx | 21 +++ .../InteractionTagMedia.stories.tsx | 25 ++++ .../InteractionTagSecondaryText.stories.tsx | 20 +++ .../InteractionTagSelected.stories.tsx | 45 ++++++ .../src/Tags/InteractionTag/index.stories.tsx | 10 ++ .../InteractionTag/interactionTag.module.css | 83 +++++++++++ .../src/Tags/Tag/TagDefault.stories.tsx | 39 +----- .../src/Tags/Tag/TagDisabled.stories.tsx | 36 +++++ .../src/Tags/Tag/TagDismiss.stories.tsx | 85 ++++++++++++ .../stories/src/Tags/Tag/TagIcon.stories.tsx | 15 ++ .../stories/src/Tags/Tag/TagMedia.stories.tsx | 26 ++++ .../src/Tags/Tag/TagSecondaryText.stories.tsx | 14 ++ .../src/Tags/Tag/TagSelected.stories.tsx | 29 ++++ .../stories/src/Tags/Tag/index.stories.tsx | 6 + .../stories/src/Tags/Tag/tag.module.css | 54 +++++++- .../Tags/TagGroup/TagGroupDefault.stories.tsx | 70 ++++------ .../TagGroup/TagGroupDisabled.stories.tsx | 45 ++++++ .../Tags/TagGroup/TagGroupDismiss.stories.tsx | 129 ++++++++++++++++++ .../TagGroup/TagGroupOverflow.stories.tsx | 71 ++++++++++ .../Tags/TagGroup/TagGroupSelect.stories.tsx | 43 ++++++ .../src/Tags/TagGroup/index.stories.tsx | 4 + .../src/Tags/TagGroup/tagGroup.module.css | 110 ++++++++++++++- 26 files changed, 1061 insertions(+), 109 deletions(-) create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDisabled.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDismiss.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagHasPrimaryAction.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagIcon.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagMedia.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSelected.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDisabled.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDismiss.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagIcon.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagMedia.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSelected.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDisabled.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDismiss.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupOverflow.stories.tsx create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupSelect.stories.tsx diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDefault.stories.tsx index afa3386173c75a..e74aa8be23560d 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDefault.stories.tsx @@ -1,35 +1,11 @@ import * as React from 'react'; import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; -import { InteractionTagSecondary } from '@fluentui/react-headless-components-preview/interaction-tag-secondary'; -import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; -import { DismissRegular } from '@fluentui/react-icons'; import styles from './interactionTag.module.css'; -const initial = [ - { value: '1', label: 'Project Alpha' }, - { value: '2', label: 'Project Beta' }, -]; - -export const Default = (): React.ReactNode => { - const [tags, setTags] = React.useState(initial); - - return ( - setTags(prev => prev.filter(t => t.value !== data.value))} - > -
- {tags.map(t => ( - - {t.label} - - - - - ))} -
-
- ); -}; +export const Default = (): React.ReactNode => ( + + Primary text + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDisabled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDisabled.stories.tsx new file mode 100644 index 00000000000000..32728d357a3f5b --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDisabled.stories.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import { InteractionTagSecondary } from '@fluentui/react-headless-components-preview/interaction-tag-secondary'; +import { CalendarMonthRegular, DismissRegular } from '@fluentui/react-icons'; + +import styles from './interactionTag.module.css'; + +export const Disabled = (): React.ReactNode => ( +
+ + }} + hasSecondaryAction + > + Disabled + + + + + +
+); + +Disabled.parameters = { + docs: { + description: { + story: + 'A disabled InteractionTag forwards `data-disabled` to its primary + secondary actions and blocks click/keyboard handlers.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDismiss.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDismiss.stories.tsx new file mode 100644 index 00000000000000..79069750d3e7ea --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDismiss.stories.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import { InteractionTagSecondary } from '@fluentui/react-headless-components-preview/interaction-tag-secondary'; +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; +import type { TagGroupProps } from '@fluentui/react-headless-components-preview/tag-group'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './interactionTag.module.css'; + +const initialTags = [ + { value: '1', children: 'Tag 1' }, + { value: '2', children: 'Tag 2' }, + { value: '3', children: 'Tag 3' }, +]; + +const useResetExample = (visibleTagsLength: number) => { + const resetButtonRef = React.useRef(null); + const firstTagRef = React.useRef(null); + const prevLength = React.useRef(visibleTagsLength); + + React.useEffect(() => { + if (visibleTagsLength === 0) { + resetButtonRef.current?.focus(); + } else if (prevLength.current === 0) { + firstTagRef.current?.focus(); + } + prevLength.current = visibleTagsLength; + }, [visibleTagsLength]); + + return { firstTagRef, resetButtonRef }; +}; + +export const Dismiss = (): React.ReactNode => { + const [visibleTags, setVisibleTags] = React.useState(initialTags); + const onDismiss: TagGroupProps['onDismiss'] = (_e, { value }) => + setVisibleTags(prev => prev.filter(t => t.value !== value)); + const { firstTagRef, resetButtonRef } = useResetExample(visibleTags.length); + + return ( +
+ {visibleTags.length > 0 && ( + +
+ {visibleTags.map((tag, i) => ( + + ) : null} + hasSecondaryAction + > + {tag.children} + + + + + + ))} +
+
+ )} + +
+ ); +}; + +Dismiss.parameters = { + docs: { + description: { + story: + 'An InteractionTag pairs a focusable primary action with an optional dismiss `InteractionTagSecondary`. The headless TagGroup does NOT restore focus after a dismiss - the consumer wires that up via refs.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagHasPrimaryAction.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagHasPrimaryAction.stories.tsx new file mode 100644 index 00000000000000..d39fd02ab0b444 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagHasPrimaryAction.stories.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import { InteractionTagSecondary } from '@fluentui/react-headless-components-preview/interaction-tag-secondary'; +import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview/popover'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './interactionTag.module.css'; + +export const HasPrimaryAction = (): React.ReactNode => ( + + + + + Golden retriever + + + + Find out more on wiki +
    +
  • Size: Medium to large-sized dog breed.
  • +
  • Coat: Luxurious double coat with a dense, water-repellent outer layer and a soft, dense undercoat.
  • +
  • Color: Typically a luscious golden or cream color, with variations in shade.
  • +
  • Build: Sturdy and well-proportioned body with a friendly and intelligent expression.
  • +
+
+
+ + + +
+); + +HasPrimaryAction.parameters = { + docs: { + description: { + story: + 'An InteractionTag can host a primary action - here, the primary opens a headless Popover. The secondary remains the dismiss affordance.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagIcon.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagIcon.stories.tsx new file mode 100644 index 00000000000000..8d70577afc70eb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagIcon.stories.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import { CalendarMonthRegular } from '@fluentui/react-icons'; + +import styles from './interactionTag.module.css'; + +export const Icon = (): React.ReactNode => ( + + }} + > + Primary text + + +); + +Icon.parameters = { + docs: { description: { story: 'An InteractionTag can render a custom icon if provided.' } }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagMedia.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagMedia.stories.tsx new file mode 100644 index 00000000000000..99d49e5a206b06 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagMedia.stories.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; + +import styles from './interactionTag.module.css'; + +export const Media = (): React.ReactNode => ( + + + Primary text + + +); + +Media.parameters = { + docs: { + description: { + story: + 'An InteractionTag can render arbitrary media in its `media` slot. The headless package does not ship an Avatar primitive - consumers project whatever element fits their design.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx new file mode 100644 index 00000000000000..09e7056f9f0988 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; + +import styles from './interactionTag.module.css'; + +export const SecondaryText = (): React.ReactNode => ( + + + Primary text + + +); + +SecondaryText.parameters = { + docs: { description: { story: 'An InteractionTag can have a secondary text.' } }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSelected.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSelected.stories.tsx new file mode 100644 index 00000000000000..c846539f430d28 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSelected.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import { InteractionTagSecondary } from '@fluentui/react-headless-components-preview/interaction-tag-secondary'; +import { CalendarMonthRegular, DismissRegular } from '@fluentui/react-icons'; + +import styles from './interactionTag.module.css'; + +export const Selected = (): React.ReactNode => ( +
+ + }} + hasSecondaryAction + > + Selected + + + + + + + }} + hasSecondaryAction + > + Not selected + + + + + +
+); + +Selected.parameters = { + docs: { + description: { + story: + 'A selected InteractionTag exposes `data-selected` on its root for styling. Selection is driven by the `selected` prop or by a TagGroup with `onTagSelect`.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/index.stories.tsx index 50ab0789c7e931..12a806570edfa3 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/index.stories.tsx @@ -1,11 +1,21 @@ import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import { InteractionTagSecondary } from '@fluentui/react-headless-components-preview/interaction-tag-secondary'; import descriptionMd from './InteractionTagDescription.md'; export { Default } from './InteractionTagDefault.stories'; +export { Icon } from './InteractionTagIcon.stories'; +export { Media } from './InteractionTagMedia.stories'; +export { SecondaryText } from './InteractionTagSecondaryText.stories'; +export { Dismiss } from './InteractionTagDismiss.stories'; +export { Disabled } from './InteractionTagDisabled.stories'; +export { HasPrimaryAction } from './InteractionTagHasPrimaryAction.stories'; +export { Selected } from './InteractionTagSelected.stories'; export default { title: 'Components/Tags/InteractionTag', component: InteractionTag, + subcomponents: { InteractionTagPrimary, InteractionTagSecondary }, parameters: { docs: { description: { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css index 86e3fcb996f9e8..18fa94f7e603fc 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css @@ -15,6 +15,10 @@ color: var(--accent-contrast); } +.interactionTag[data-disabled] { + opacity: 0.55; +} + .primary { appearance: none; border: none; @@ -43,6 +47,40 @@ opacity: 0.5; } +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.media { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-left: -6px; + border-radius: 50%; + background: var(--accent); + color: var(--accent-contrast); + font-size: 11px; + font-weight: 600; +} + +.primaryText { + display: inline-flex; + align-items: baseline; +} + +.secondaryText { + margin-left: 4px; + font-size: 11px; + color: var(--text-muted); +} + .secondary { appearance: none; border: none; @@ -66,3 +104,48 @@ align-items: center; flex-wrap: wrap; } + +.demoCol { + display: flex; + flex-direction: column; + gap: 16px; + align-items: flex-start; +} + +.resetButton { + appearance: none; + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + padding: 4px 10px; + border-radius: var(--radius-md); + font-size: 12px; + cursor: pointer; + width: fit-content; +} + +.resetButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.popover { + width: 360px; + max-width: 100%; + padding: 16px; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); +} + +.popover ul { + margin: 8px 0 0; + padding-left: 20px; +} + +.popover a { + color: var(--accent); + text-decoration: underline; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDefault.stories.tsx index f0f29f082a6d49..6f21cc29a46c46 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDefault.stories.tsx @@ -1,43 +1,6 @@ import * as React from 'react'; import { Tag } from '@fluentui/react-headless-components-preview/tag'; -import { DismissRegular } from '@fluentui/react-icons'; import styles from './tag.module.css'; -export const Default = (): React.ReactNode => ( -
-
- Default - - Selected - - - Disabled - -
- -
- , - }} - > - Dismissible - - , - }} - > - Disabled & dismissible - -
-
-); +export const Default = (): React.ReactNode => Primary text; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDisabled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDisabled.stories.tsx new file mode 100644 index 00000000000000..30821230933d08 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDisabled.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { CalendarMonthRegular, DismissRegular } from '@fluentui/react-icons'; + +import styles from './tag.module.css'; + +export const Disabled = (): React.ReactNode => ( +
+ }} + > + Disabled + + }} + dismissIcon={{ + className: styles.dismissIcon, + 'aria-label': 'remove', + children: , + }} + > + Disabled & dismissible + +
+); + +Disabled.parameters = { + docs: { + description: { story: 'A disabled Tag exposes `data-disabled` for styling and blocks the dismiss handler.' }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDismiss.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDismiss.stories.tsx new file mode 100644 index 00000000000000..8c8bcb3d210ebb --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDismiss.stories.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; +import type { TagGroupProps } from '@fluentui/react-headless-components-preview/tag-group'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tag.module.css'; + +const initialTags = [ + { value: '1', children: 'Tag 1' }, + { value: '2', children: 'Tag 2' }, + { value: '3', children: 'Tag 3' }, +]; + +// Focus management is consumer-supplied in headless: after the last tag is dismissed, +// move focus to the reset button; when the list refills, focus the first tag. +const useResetExample = (visibleTagsLength: number) => { + const resetButtonRef = React.useRef(null); + const firstTagRef = React.useRef(null); + const prevLength = React.useRef(visibleTagsLength); + + React.useEffect(() => { + if (visibleTagsLength === 0) { + resetButtonRef.current?.focus(); + } else if (prevLength.current === 0) { + firstTagRef.current?.focus(); + } + prevLength.current = visibleTagsLength; + }, [visibleTagsLength]); + + return { firstTagRef, resetButtonRef }; +}; + +export const Dismiss = (): React.ReactNode => { + const [visibleTags, setVisibleTags] = React.useState(initialTags); + const onDismiss: TagGroupProps['onDismiss'] = (_e, { value }) => { + setVisibleTags(prev => prev.filter(t => t.value !== value)); + }; + const { firstTagRef, resetButtonRef } = useResetExample(visibleTags.length); + + return ( +
+ {visibleTags.length > 0 && ( + +
+ {visibleTags.map((tag, i) => ( + ) : null} + dismissIcon={{ + className: styles.dismissIcon, + 'aria-label': 'remove', + children: , + }} + > + {tag.children} + + ))} +
+
+ )} + +
+ ); +}; + +Dismiss.parameters = { + docs: { + description: { + story: + 'A Tag can be dismissible. TagGroup handles dismissal via `onDismiss`. The headless TagGroup does NOT auto-restore focus - the consumer wires focus management here via refs.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagIcon.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagIcon.stories.tsx new file mode 100644 index 00000000000000..18d941890cf38f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagIcon.stories.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { CalendarMonthRegular } from '@fluentui/react-icons'; + +import styles from './tag.module.css'; + +export const Icon = (): React.ReactNode => ( + }}> + Primary text + +); + +Icon.parameters = { + docs: { description: { story: 'A Tag can render a custom icon if provided.' } }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagMedia.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagMedia.stories.tsx new file mode 100644 index 00000000000000..e22b0878e23f33 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagMedia.stories.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; + +import styles from './tag.module.css'; + +export const Media = (): React.ReactNode => ( + + Primary text + +); + +Media.parameters = { + docs: { + description: { + story: + 'A Tag can render arbitrary media in its `media` slot. Headless does not ship an Avatar primitive - consumers project whatever element fits their design (here, an initials chip).', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx new file mode 100644 index 00000000000000..b9551c5e5b4b82 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; + +import styles from './tag.module.css'; + +export const SecondaryText = (): React.ReactNode => ( + + Primary text + +); + +SecondaryText.parameters = { + docs: { description: { story: 'A Tag can have a secondary text.' } }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSelected.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSelected.stories.tsx new file mode 100644 index 00000000000000..01674c232c2e3e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSelected.stories.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { CalendarMonthRegular } from '@fluentui/react-icons'; + +import styles from './tag.module.css'; + +export const Selected = (): React.ReactNode => ( +
+ }} + > + Selected + + }}> + Not selected + +
+); + +Selected.parameters = { + docs: { + description: { + story: + 'A selected Tag exposes `data-selected` on its root and sets `aria-pressed="true"` (or `aria-selected` when the parent TagGroup uses `role="listbox"`).', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/index.stories.tsx index 56233261cf8eff..ca68fad7b102b5 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/index.stories.tsx @@ -2,6 +2,12 @@ import { Tag } from '@fluentui/react-headless-components-preview/tag'; import descriptionMd from './TagDescription.md'; export { Default } from './TagDefault.stories'; +export { Icon } from './TagIcon.stories'; +export { Media } from './TagMedia.stories'; +export { SecondaryText } from './TagSecondaryText.stories'; +export { Dismiss } from './TagDismiss.stories'; +export { Disabled } from './TagDisabled.stories'; +export { Selected } from './TagSelected.stories'; export default { title: 'Components/Tags/Tag', diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css index 07528538f0a773..d467f35d735eeb 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css @@ -3,7 +3,7 @@ display: inline-flex; align-items: center; gap: 6px; - height: 24px; + height: 28px; padding: 0 10px; border-radius: var(--radius-pill); border: 1px solid var(--border-strong); @@ -49,6 +49,40 @@ background: var(--surface-muted); } +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.media { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-left: -4px; + border-radius: 50%; + background: var(--accent); + color: var(--accent-contrast); + font-size: 11px; + font-weight: 600; +} + +.primaryText { + display: inline-flex; + align-items: baseline; +} + +.secondaryText { + margin-left: 4px; + font-size: 11px; + color: var(--text-muted); +} + .dismissIcon { display: inline-flex; align-items: center; @@ -67,6 +101,7 @@ display: flex; flex-direction: column; gap: 16px; + align-items: flex-start; } .demoRow { @@ -75,3 +110,20 @@ align-items: center; flex-wrap: wrap; } + +.resetButton { + appearance: none; + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + padding: 4px 10px; + border-radius: var(--radius-md); + font-size: 12px; + cursor: pointer; + width: fit-content; +} + +.resetButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDefault.stories.tsx index c6f05656dc2f86..9d8d7298333fe4 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDefault.stories.tsx @@ -1,47 +1,37 @@ import * as React from 'react'; import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; import { Tag } from '@fluentui/react-headless-components-preview/tag'; -import { DismissRegular } from '@fluentui/react-icons'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; import styles from './tagGroup.module.css'; -const initialTags = [ - { value: '1', label: 'Tag one' }, - { value: '2', label: 'Tag two' }, - { value: '3', label: 'Tag three' }, -]; +export const Default = (): React.ReactNode => ( +
+
Example with Tag:
+ + + Tag 1 + + + Tag 2 + + + Tag 3 + + -export const Default = (): React.ReactNode => { - const [tags, setTags] = React.useState(initialTags); - - return ( -
- setTags(prev => prev.filter(t => t.value !== data.value))} - > - {tags.map(t => ( - }} - > - {t.label} - - ))} - - - - }}> - Locked - - }}> - Read-only - - -
- ); -}; +
Example with InteractionTag:
+ + + Tag 1 + + + Tag 2 + + + Tag 3 + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDisabled.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDisabled.stories.tsx new file mode 100644 index 00000000000000..b561b0e459dfc9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDisabled.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; + +import styles from './tagGroup.module.css'; + +export const Disabled = (): React.ReactNode => ( +
+
Disabled example with Tag:
+ + + Tag 1 + + + Tag 2 + + + Tag 3 + + + +
Disabled example with InteractionTag:
+ + + Tag 1 + + + Tag 2 + + + Tag 3 + + +
+); + +Disabled.parameters = { + docs: { + description: { + story: 'A disabled TagGroup propagates `disabled` to every child Tag / InteractionTag via the group context.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDismiss.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDismiss.stories.tsx new file mode 100644 index 00000000000000..8628bbeb73c3b1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDismiss.stories.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; +import type { TagGroupProps } from '@fluentui/react-headless-components-preview/tag-group'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import { InteractionTagSecondary } from '@fluentui/react-headless-components-preview/interaction-tag-secondary'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tagGroup.module.css'; + +const initialTags = [ + { value: '1', children: 'Tag 1' }, + { value: '2', children: 'Tag 2' }, + { value: '3', children: 'Tag 3' }, +]; + +const useResetExample = (visibleTagsLength: number) => { + const resetButtonRef = React.useRef(null); + const firstTagRef = React.useRef(null); + const prevLength = React.useRef(visibleTagsLength); + React.useEffect(() => { + if (visibleTagsLength === 0) { + resetButtonRef.current?.focus(); + } else if (prevLength.current === 0) { + firstTagRef.current?.focus(); + } + prevLength.current = visibleTagsLength; + }, [visibleTagsLength]); + return { firstTagRef, resetButtonRef }; +}; + +const DismissWithTags = () => { + const [visibleTags, setVisibleTags] = React.useState(initialTags); + const onDismiss: TagGroupProps['onDismiss'] = (_e, { value }) => + setVisibleTags(prev => prev.filter(t => t.value !== value)); + const { firstTagRef, resetButtonRef } = useResetExample(visibleTags.length); + + return ( + <> + {visibleTags.length > 0 && ( + + {visibleTags.map((tag, i) => ( + ) : null} + dismissIcon={{ + className: styles.dismissIcon, + 'aria-label': 'remove', + children: , + }} + > + {tag.children} + + ))} + + )} + + + ); +}; + +const DismissWithInteractionTags = () => { + const [visibleTags, setVisibleTags] = React.useState(initialTags); + const onDismiss: TagGroupProps['onDismiss'] = (_e, { value }) => + setVisibleTags(prev => prev.filter(t => t.value !== value)); + const { firstTagRef, resetButtonRef } = useResetExample(visibleTags.length); + + return ( + <> + {visibleTags.length > 0 && ( + + {visibleTags.map((tag, i) => ( + + ) : null} + hasSecondaryAction + > + {tag.children} + + + + + + ))} + + )} + + + ); +}; + +export const Dismiss = (): React.ReactNode => ( +
+
Example with Tag:
+ +
Example with InteractionTag:
+ +
+); + +Dismiss.parameters = { + docs: { + description: { + story: + 'A TagGroup contains a collection of Tag / InteractionTag children that can be dismissed. The headless TagGroup does NOT restore focus after dismissal - consumers wire that up themselves with refs.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupOverflow.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupOverflow.stories.tsx new file mode 100644 index 00000000000000..5e27438d81688c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupOverflow.stories.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { DismissRegular } from '@fluentui/react-icons'; + +import styles from './tagGroup.module.css'; + +const allTags = Array.from({ length: 11 }, (_, i) => ({ + value: String(i + 1), + children: `Tag ${i + 1}`, +})); + +const COLLAPSED_COUNT = 4; + +export const Overflow = (): React.ReactNode => { + const [expanded, setExpanded] = React.useState(false); + const [visibleTags, setVisibleTags] = React.useState(allTags); + + const visible = expanded ? visibleTags : visibleTags.slice(0, COLLAPSED_COUNT); + const hiddenCount = Math.max(0, visibleTags.length - COLLAPSED_COUNT); + + return ( +
+ setVisibleTags(prev => prev.filter(t => t.value !== value))} + > + {visible.map(t => ( + , + }} + > + {t.children} + + ))} + {!expanded && hiddenCount > 0 && ( + + )} + {expanded && visibleTags.length > COLLAPSED_COUNT && ( + + )} + +
+ ); +}; + +Overflow.parameters = { + docs: { + description: { + story: + 'Headless has no built-in overflow primitive. Consumers control which tags render and surface the rest however they like - here, the first 4 tags render and a `+N more` button toggles a "show all" mode.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupSelect.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupSelect.stories.tsx new file mode 100644 index 00000000000000..934a648c8ddff1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupSelect.stories.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; +import type { TagGroupProps } from '@fluentui/react-headless-components-preview/tag-group'; +import { InteractionTag } from '@fluentui/react-headless-components-preview/interaction-tag'; +import { InteractionTagPrimary } from '@fluentui/react-headless-components-preview/interaction-tag-primary'; +import type { TagValue } from '@fluentui/react-components'; + +import styles from './tagGroup.module.css'; + +const tags: Array<{ value: string; children: string }> = [ + { value: '1', children: 'Tag 1' }, + { value: '2', children: 'Tag 2' }, + { value: '3', children: 'Tag 3' }, +]; + +export const Select = (): React.ReactNode => { + const [selectedTags, setSelectedTags] = React.useState>([]); + const onTagSelect: TagGroupProps['onTagSelect'] = (_e, { value }) => { + setSelectedTags(prev => (prev.includes(value) ? prev.filter(t => t !== value) : [...prev, value])); + }; + + return ( +
+
Selected values: [{selectedTags.join(', ')}]
+ + {tags.map(t => ( + + {t.children} + + ))} + +
+ ); +}; + +Select.parameters = { + docs: { + description: { + story: + 'A TagGroup with `onTagSelect` enables multi-select on InteractionTag children. Selection state is forwarded through context; the headless layer flips `aria-pressed` and `data-selected` on the primary.', + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx index 177d0d29891aef..3156a28fad8242 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx @@ -2,6 +2,10 @@ import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group' import descriptionMd from './TagGroupDescription.md'; export { Default } from './TagGroupDefault.stories'; +export { Dismiss } from './TagGroupDismiss.stories'; +export { Disabled } from './TagGroupDisabled.stories'; +export { Select } from './TagGroupSelect.stories'; +export { Overflow } from './TagGroupOverflow.stories'; export default { title: 'Components/Tags/TagGroup', diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css index 178799fb90cb83..8df7f7162aff21 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css @@ -16,7 +16,7 @@ display: inline-flex; align-items: center; gap: 6px; - height: 24px; + height: 28px; padding: 0 0 0 10px; border-radius: var(--radius-pill); background: var(--surface-muted); @@ -35,6 +35,63 @@ cursor: not-allowed; } +.interactionTag { + display: inline-flex; + align-items: stretch; + border-radius: var(--radius-pill); + overflow: hidden; + border: 1px solid var(--border-strong); + background: var(--surface); + font-size: 12px; + height: 28px; +} + +.interactionTag[data-selected] { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); +} + +.primary { + appearance: none; + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 0 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.primary:hover { + background: var(--surface-muted); +} + +.primary:focus-visible, +.secondary:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-elev) inset, 0 0 0 4px var(--accent) inset; +} + +.secondary { + appearance: none; + border: none; + background: transparent; + color: inherit; + padding: 0 10px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + border-left: 1px solid var(--border); +} + +.secondary:hover { + background: var(--surface-muted); +} + .dismissIcon { display: inline-flex; align-items: center; @@ -47,4 +104,55 @@ display: flex; flex-direction: column; gap: 16px; + align-items: flex-start; +} + +.label { + font-size: 12px; + color: var(--text-muted); +} + +.resetButton { + appearance: none; + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + padding: 4px 10px; + border-radius: var(--radius-md); + font-size: 12px; + cursor: pointer; + width: fit-content; +} + +.resetButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.selectedReadout { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono, monospace); +} + +.overflowContainer { + width: 100%; + resize: horizontal; + overflow: hidden; + padding: 6px; + border: 1px dashed var(--border); + border-radius: var(--radius-md); + min-width: 200px; +} + +.overflowMore { + appearance: none; + border: 1px dashed var(--border-strong); + background: transparent; + color: var(--text); + font: inherit; + padding: 0 10px; + height: 28px; + border-radius: var(--radius-pill); + cursor: pointer; } From e40d2e6b7f397d8a7a7893d09d1843e5cc756667 Mon Sep 17 00:00:00 2001 From: mainframev Date: Wed, 20 May 2026 03:07:11 +0200 Subject: [PATCH 9/9] fix(stories): visual fixes to Tag, InteractionTag, TagGroup stories --- ...InteractionTagHasPrimaryAction.stories.tsx | 8 +- .../InteractionTagSecondaryText.stories.tsx | 5 +- .../InteractionTag/interactionTag.module.css | 114 +++++++++++------ .../src/Tags/Tag/TagSecondaryText.stories.tsx | 8 +- .../stories/src/Tags/Tag/tag.module.css | 60 ++++++--- .../TagGroup/TagGroupOverflow.stories.tsx | 71 ----------- .../src/Tags/TagGroup/index.stories.tsx | 1 - .../src/Tags/TagGroup/tagGroup.module.css | 119 +----------------- 8 files changed, 139 insertions(+), 247 deletions(-) delete mode 100644 packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupOverflow.stories.tsx diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagHasPrimaryAction.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagHasPrimaryAction.stories.tsx index d39fd02ab0b444..e32dbe63654290 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagHasPrimaryAction.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagHasPrimaryAction.stories.tsx @@ -18,10 +18,10 @@ export const HasPrimaryAction = (): React.ReactNode => ( Find out more on wiki
    -
  • Size: Medium to large-sized dog breed.
  • -
  • Coat: Luxurious double coat with a dense, water-repellent outer layer and a soft, dense undercoat.
  • -
  • Color: Typically a luscious golden or cream color, with variations in shade.
  • -
  • Build: Sturdy and well-proportioned body with a friendly and intelligent expression.
  • +
  • Medium to large-sized breed
  • +
  • Dense, water-repellent coat
  • +
  • Golden or cream color
  • +
  • Friendly, intelligent
diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx index 09e7056f9f0988..92c1e920d39841 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx @@ -8,10 +8,9 @@ export const SecondaryText = (): React.ReactNode => ( - Primary text - + /> ); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css index 18fa94f7e603fc..bbdab566a8e722 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css @@ -1,12 +1,14 @@ .interactionTag { display: inline-flex; align-items: stretch; - border-radius: var(--radius-pill); + border-radius: var(--radius-md); overflow: hidden; border: 1px solid var(--border-strong); background: var(--surface); font-size: 12px; - height: 28px; + min-height: 28px; + width: max-content; + max-width: 100%; } .interactionTag[data-selected] { @@ -25,21 +27,24 @@ background: transparent; color: inherit; font: inherit; - padding: 0 12px; + padding: 4px 12px; cursor: pointer; - display: inline-flex; + display: inline-grid; + grid-template-areas: + 'media primary' + 'media secondary'; align-items: center; - gap: 6px; + align-content: center; + justify-items: start; + border-radius: calc(var(--radius-md) - 1px) 0 0 calc(var(--radius-md) - 1px); } .primary:hover { background: var(--surface-muted); } -.primary:focus-visible, -.secondary:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--bg-elev) inset, 0 0 0 4px var(--accent) inset; +.interactionTag[data-selected] .primary:hover { + background: color-mix(in srgb, var(--accent) 85%, white); } .primary[data-disabled] { @@ -47,19 +52,59 @@ opacity: 0.5; } -.icon { +.secondary { + appearance: none; + border: none; + background: transparent; + color: inherit; + padding: 0 10px; + cursor: pointer; display: inline-flex; align-items: center; justify-content: center; - width: 14px; - height: 14px; - flex-shrink: 0; + border-left: 1px solid var(--border); + border-radius: 0 calc(var(--radius-md) - 1px) calc(var(--radius-md) - 1px) 0; +} + +.secondary:hover { + background: var(--surface-muted); +} + +.interactionTag[data-selected] .secondary { + border-left-color: color-mix(in srgb, var(--accent-contrast) 30%, transparent); +} + +.interactionTag[data-selected] .secondary:hover { + background: color-mix(in srgb, var(--accent) 85%, white); +} + +.interactionTag:not(:has(.secondary)) .primary { + border-radius: calc(var(--radius-md) - 1px); +} + +.primary:focus-visible, +.secondary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.interactionTag[data-selected] .primary:focus-visible, +.interactionTag[data-selected] .secondary:focus-visible { + outline-color: var(--accent-contrast); +} + +.icon { + composes: icon from '../Tag/tag.module.css'; } .media { + grid-area: media; + align-self: center; display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; + margin-right: 6px; width: 24px; height: 24px; margin-left: -6px; @@ -71,31 +116,19 @@ } .primaryText { - display: inline-flex; - align-items: baseline; + composes: primaryText from '../Tag/tag.module.css'; } -.secondaryText { - margin-left: 4px; - font-size: 11px; - color: var(--text-muted); +.primary:not(:has(> .secondaryText)) > .primaryText { + grid-row-end: secondary; } -.secondary { - appearance: none; - border: none; - background: transparent; - color: inherit; - padding: 0 10px; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - border-left: 1px solid var(--border); +.secondaryText { + composes: secondaryText from '../Tag/tag.module.css'; } -.secondary:hover { - background: var(--surface-muted); +.interactionTag[data-selected] .secondaryText { + color: color-mix(in srgb, var(--accent-contrast) 80%, transparent); } .demo { @@ -130,19 +163,26 @@ } .popover { - width: 360px; - max-width: 100%; - padding: 16px; + width: max-content; + max-width: min(320px, calc(100vw - 32px)); + padding: 12px 14px; background: var(--surface); color: var(--text); + font-size: 12px; + line-height: 1.4; border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); + box-sizing: border-box; } .popover ul { - margin: 8px 0 0; - padding-left: 20px; + margin: 6px 0 0; + padding-left: 18px; +} + +.popover li + li { + margin-top: 2px; } .popover a { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx index b9551c5e5b4b82..65438762158636 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx @@ -4,9 +4,11 @@ import { Tag } from '@fluentui/react-headless-components-preview/tag'; import styles from './tag.module.css'; export const SecondaryText = (): React.ReactNode => ( - - Primary text - + ); SecondaryText.parameters = { diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css index d467f35d735eeb..564d03937447f1 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css @@ -1,11 +1,16 @@ -/* tag — outlined pill with selectable + dismissible states */ .tag { - display: inline-flex; + display: inline-grid; + grid-template-areas: + 'media primary dismiss' + 'media secondary dismiss'; align-items: center; - gap: 6px; - height: 28px; + align-content: center; + justify-items: start; + min-height: 28px; + width: max-content; + max-width: 100%; padding: 0 10px; - border-radius: var(--radius-pill); + border-radius: var(--radius-md); border: 1px solid var(--border-strong); background: var(--surface); color: var(--text); @@ -34,12 +39,20 @@ box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); } +.tag[data-selected][data-dismissible]:focus-visible { + box-shadow: 0 0 0 2px var(--accent), 0 0 0 4px var(--accent-contrast); +} + .tag[data-selected] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); } +.tag[data-selected][data-dismissible]:hover { + background: color-mix(in srgb, var(--accent) 85%, white); +} + .tag[data-disabled] { opacity: 0.4; cursor: not-allowed; @@ -49,22 +62,27 @@ background: var(--surface-muted); } -.icon { +.icon, +.media { + grid-area: media; + align-self: center; display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; +} + +.icon { width: 14px; height: 14px; - flex-shrink: 0; + margin-right: 6px; } .media { - display: inline-flex; - align-items: center; - justify-content: center; width: 22px; height: 22px; margin-left: -4px; + margin-right: 6px; border-radius: 50%; background: var(--accent); color: var(--accent-contrast); @@ -73,30 +91,42 @@ } .primaryText { - display: inline-flex; - align-items: baseline; + grid-area: primary; + line-height: 1.2; +} + +.tag:not(:has(> .secondaryText)) > .primaryText { + grid-row-end: secondary; } .secondaryText { - margin-left: 4px; + grid-area: secondary; font-size: 11px; color: var(--text-muted); + line-height: 1.2; } .dismissIcon { + grid-area: dismiss; + align-self: center; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; - margin-left: 2px; - border-radius: 50%; + margin-left: 6px; + border-radius: var(--radius-sm, 4px); + flex-shrink: 0; } .tag[data-dismissible]:hover .dismissIcon { background: var(--surface); } +.tag[data-selected] .secondaryText { + color: color-mix(in srgb, var(--accent-contrast) 80%, transparent); +} + .demo { display: flex; flex-direction: column; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupOverflow.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupOverflow.stories.tsx deleted file mode 100644 index 5e27438d81688c..00000000000000 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupOverflow.stories.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from 'react'; -import { TagGroup } from '@fluentui/react-headless-components-preview/tag-group'; -import { Tag } from '@fluentui/react-headless-components-preview/tag'; -import { DismissRegular } from '@fluentui/react-icons'; - -import styles from './tagGroup.module.css'; - -const allTags = Array.from({ length: 11 }, (_, i) => ({ - value: String(i + 1), - children: `Tag ${i + 1}`, -})); - -const COLLAPSED_COUNT = 4; - -export const Overflow = (): React.ReactNode => { - const [expanded, setExpanded] = React.useState(false); - const [visibleTags, setVisibleTags] = React.useState(allTags); - - const visible = expanded ? visibleTags : visibleTags.slice(0, COLLAPSED_COUNT); - const hiddenCount = Math.max(0, visibleTags.length - COLLAPSED_COUNT); - - return ( -
- setVisibleTags(prev => prev.filter(t => t.value !== value))} - > - {visible.map(t => ( - , - }} - > - {t.children} - - ))} - {!expanded && hiddenCount > 0 && ( - - )} - {expanded && visibleTags.length > COLLAPSED_COUNT && ( - - )} - -
- ); -}; - -Overflow.parameters = { - docs: { - description: { - story: - 'Headless has no built-in overflow primitive. Consumers control which tags render and surface the rest however they like - here, the first 4 tags render and a `+N more` button toggles a "show all" mode.', - }, - }, -}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx index 3156a28fad8242..44ae930fe618c8 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx @@ -5,7 +5,6 @@ export { Default } from './TagGroupDefault.stories'; export { Dismiss } from './TagGroupDismiss.stories'; export { Disabled } from './TagGroupDisabled.stories'; export { Select } from './TagGroupSelect.stories'; -export { Overflow } from './TagGroupOverflow.stories'; export default { title: 'Components/Tags/TagGroup', diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css index 8df7f7162aff21..37db40626d9bf0 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css @@ -2,10 +2,6 @@ display: inline-flex; flex-wrap: wrap; gap: 6px; - padding: 6px; - border-radius: var(--radius-md); - background: var(--surface); - border: 1px solid var(--border); } .group[data-disabled] { @@ -13,91 +9,23 @@ } .tag { - display: inline-flex; - align-items: center; - gap: 6px; - height: 28px; - padding: 0 0 0 10px; - border-radius: var(--radius-pill); - background: var(--surface-muted); - color: var(--text); - font-size: 12px; - cursor: pointer; - border: none; -} - -.tag:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--bg-elev), 0 0 0 4px var(--accent); -} - -.tag[data-disabled] { - cursor: not-allowed; + composes: tag from '../Tag/tag.module.css'; } .interactionTag { - display: inline-flex; - align-items: stretch; - border-radius: var(--radius-pill); - overflow: hidden; - border: 1px solid var(--border-strong); - background: var(--surface); - font-size: 12px; - height: 28px; -} - -.interactionTag[data-selected] { - background: var(--accent); - border-color: var(--accent); - color: var(--accent-contrast); + composes: interactionTag from '../InteractionTag/interactionTag.module.css'; } .primary { - appearance: none; - border: none; - background: transparent; - color: inherit; - font: inherit; - padding: 0 12px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 6px; -} - -.primary:hover { - background: var(--surface-muted); -} - -.primary:focus-visible, -.secondary:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--bg-elev) inset, 0 0 0 4px var(--accent) inset; + composes: primary from '../InteractionTag/interactionTag.module.css'; } .secondary { - appearance: none; - border: none; - background: transparent; - color: inherit; - padding: 0 10px; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - border-left: 1px solid var(--border); -} - -.secondary:hover { - background: var(--surface-muted); + composes: secondary from '../InteractionTag/interactionTag.module.css'; } .dismissIcon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; + composes: dismissIcon from '../Tag/tag.module.css'; } .demo { @@ -113,20 +41,7 @@ } .resetButton { - appearance: none; - border: 1px solid var(--border-strong); - background: var(--surface); - color: var(--text); - padding: 4px 10px; - border-radius: var(--radius-md); - font-size: 12px; - cursor: pointer; - width: fit-content; -} - -.resetButton:disabled { - opacity: 0.4; - cursor: not-allowed; + composes: resetButton from '../Tag/tag.module.css'; } .selectedReadout { @@ -134,25 +49,3 @@ color: var(--text-muted); font-family: var(--font-mono, monospace); } - -.overflowContainer { - width: 100%; - resize: horizontal; - overflow: hidden; - padding: 6px; - border: 1px dashed var(--border); - border-radius: var(--radius-md); - min-width: 200px; -} - -.overflowMore { - appearance: none; - border: 1px dashed var(--border-strong); - background: transparent; - color: var(--text); - font: inherit; - padding: 0 10px; - height: 28px; - border-radius: var(--radius-pill); - cursor: pointer; -}