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..ee5985661be4bb --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-3cc555dc-e346-4c29-bcd0-c8dfbf38544d.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "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 new file mode 100644 index 00000000000000..3ecb2152a29376 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-primary.api.md @@ -0,0 +1,46 @@ +## 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 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 * as React_2 from 'react'; +import { renderInteractionTagPrimary_unstable as renderInteractionTagPrimary } from '@fluentui/react-tags'; + +// @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; + }; +}; + +export { renderInteractionTagPrimary } + +// @public +export const useInteractionTagPrimary: (props: InteractionTagPrimaryProps, ref: React_2.Ref) => InteractionTagPrimaryState; + +// @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-secondary.api.md b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md new file mode 100644 index 00000000000000..318156d797e6bf --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-tag-secondary.api.md @@ -0,0 +1,38 @@ +## 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 type { InteractionTagSecondaryBaseState } from '@fluentui/react-tags'; +import type { InteractionTagSecondarySlots as InteractionTagSecondarySlots_2 } from '@fluentui/react-tags'; +import type * as React_2 from 'react'; +import { renderInteractionTagSecondary_unstable as renderInteractionTagSecondary } from '@fluentui/react-tags'; + +// @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; + }; +}; + +export { renderInteractionTagSecondary } + +// @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..6ae31ecfbfbd76 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/interaction-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 type { InteractionTagBaseProps } 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 * as React_2 from 'react'; +import { renderInteractionTag_unstable as renderInteractionTag } from '@fluentui/react-tags'; + +// @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; + }; +}; + +export { renderInteractionTag } + +// @public +export const useInteractionTag: (props: InteractionTagProps, ref: React_2.Ref) => InteractionTagState; + +// @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-group.api.md b/packages/react-components/react-headless-components-preview/library/etc/tag-group.api.md new file mode 100644 index 00000000000000..5e98d71a59af2e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/tag-group.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 * as React_2 from 'react'; +import { renderTagGroup_unstable as renderTagGroup } from '@fluentui/react-tags'; +import type { TagGroupBaseProps } from '@fluentui/react-tags'; +import type { TagGroupBaseState } from '@fluentui/react-tags'; +import type { TagGroupContextValue } from '@fluentui/react-tags'; +import type { TagGroupSlots as TagGroupSlots_2 } from '@fluentui/react-tags'; + +export { renderTagGroup } + +// @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 (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 new file mode 100644 index 00000000000000..218f4cd57791a3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/tag.api.md @@ -0,0 +1,46 @@ +## 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 * as React_2 from 'react'; +import { renderTag_unstable as renderTag } from '@fluentui/react-tags'; +import type { TagBaseProps } 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'; + +export { renderTag } + +// @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 (undocumented) +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..514b8de2a53c34 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/renderInteractionTag.ts @@ -0,0 +1 @@ +export { renderInteractionTag_unstable as renderInteractionTag } from '@fluentui/react-tags'; 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..5584582875e113 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTag/useInteractionTag.ts @@ -0,0 +1,46 @@ +'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 => { + 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; +}; + +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..fbc6a6d8fc5b94 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/renderInteractionTagPrimary.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000000000..ab1e5f057e6ad3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagPrimary/useInteractionTagPrimary.ts @@ -0,0 +1,39 @@ +'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 => { + 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; +}; + +const emptyAvatarContext = { size: undefined, shape: undefined } as const; + +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..f03f0851dd0b3d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/renderInteractionTagSecondary.ts @@ -0,0 +1 @@ +export { renderInteractionTagSecondary_unstable as renderInteractionTagSecondary } from '@fluentui/react-tags'; 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..7d8154e54a6806 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/InteractionTagSecondary/useInteractionTagSecondary.ts @@ -0,0 +1,25 @@ +'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`. + */ +export const useInteractionTagSecondary = ( + props: InteractionTagSecondaryProps, + ref: React.Ref, +): 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/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..f13ab6789640b8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/renderTag.ts @@ -0,0 +1 @@ +export { renderTag_unstable as renderTag } from '@fluentui/react-tags'; 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..21a8ce1c94c25d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tag/useTag.ts @@ -0,0 +1,30 @@ +'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 => { + 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; +}; + +const emptyAvatarContext = { size: undefined, shape: undefined } as const; + +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..3982cd6cee57dc --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/renderTagGroup.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000000000..30b8f6f134a499 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/TagGroup/useTagGroup.ts @@ -0,0 +1,42 @@ +'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. + */ +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; +}; + +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'; 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 new file mode 100644 index 00000000000000..e74aa8be23560d --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDefault.stories.tsx @@ -0,0 +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 styles from './interactionTag.module.css'; + +export const Default = (): React.ReactNode => ( + + Primary text + +); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagDescription.md new file mode 100644 index 00000000000000..1193955b6c6a33 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/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/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..e32dbe63654290 --- /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 +
    +
  • Medium to large-sized breed
  • +
  • Dense, water-repellent coat
  • +
  • Golden or cream color
  • +
  • Friendly, intelligent
  • +
+
+
+ + + +
+); + +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..92c1e920d39841 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/InteractionTagSecondaryText.stories.tsx @@ -0,0 +1,19 @@ +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 => ( + + + +); + +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 new file mode 100644 index 00000000000000..12a806570edfa3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/index.stories.tsx @@ -0,0 +1,26 @@ +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: { + component: descriptionMd, + }, + }, + }, +}; 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 new file mode 100644 index 00000000000000..bbdab566a8e722 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/InteractionTag/interactionTag.module.css @@ -0,0 +1,191 @@ +.interactionTag { + display: inline-flex; + align-items: stretch; + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--border-strong); + background: var(--surface); + font-size: 12px; + min-height: 28px; + width: max-content; + max-width: 100%; +} + +.interactionTag[data-selected] { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-contrast); +} + +.interactionTag[data-disabled] { + opacity: 0.55; +} + +.primary { + appearance: none; + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 4px 12px; + cursor: pointer; + display: inline-grid; + grid-template-areas: + 'media primary' + 'media secondary'; + align-items: center; + 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); +} + +.interactionTag[data-selected] .primary:hover { + background: color-mix(in srgb, var(--accent) 85%, white); +} + +.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); + 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; + border-radius: 50%; + background: var(--accent); + color: var(--accent-contrast); + font-size: 11px; + font-weight: 600; +} + +.primaryText { + composes: primaryText from '../Tag/tag.module.css'; +} + +.primary:not(:has(> .secondaryText)) > .primaryText { + grid-row-end: secondary; +} + +.secondaryText { + composes: secondaryText from '../Tag/tag.module.css'; +} + +.interactionTag[data-selected] .secondaryText { + color: color-mix(in srgb, var(--accent-contrast) 80%, transparent); +} + +.demo { + display: flex; + gap: 8px; + 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: 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: 6px 0 0; + padding-left: 18px; +} + +.popover li + li { + margin-top: 2px; +} + +.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 new file mode 100644 index 00000000000000..6f21cc29a46c46 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDefault.stories.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; + +import styles from './tag.module.css'; + +export const Default = (): React.ReactNode => Primary text; diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagDescription.md new file mode 100644 index 00000000000000..1dbdae15a170d9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/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/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..65438762158636 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/TagSecondaryText.stories.tsx @@ -0,0 +1,16 @@ +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 => ( + +); + +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 new file mode 100644 index 00000000000000..ca68fad7b102b5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/index.stories.tsx @@ -0,0 +1,22 @@ +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', + component: Tag, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; 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 new file mode 100644 index 00000000000000..564d03937447f1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/Tag/tag.module.css @@ -0,0 +1,159 @@ +.tag { + display: inline-grid; + grid-template-areas: + 'media primary dismiss' + 'media secondary dismiss'; + align-items: center; + align-content: center; + justify-items: start; + min-height: 28px; + width: max-content; + max-width: 100%; + padding: 0 10px; + border-radius: var(--radius-md); + 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][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; +} + +.tag[data-disabled][data-dismissible]:hover { + background: var(--surface-muted); +} + +.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; + margin-right: 6px; +} + +.media { + width: 22px; + height: 22px; + margin-left: -4px; + margin-right: 6px; + border-radius: 50%; + background: var(--accent); + color: var(--accent-contrast); + font-size: 11px; + font-weight: 600; +} + +.primaryText { + grid-area: primary; + line-height: 1.2; +} + +.tag:not(:has(> .secondaryText)) > .primaryText { + grid-row-end: secondary; +} + +.secondaryText { + 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: 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; + gap: 16px; + align-items: flex-start; +} + +.demoRow { + display: flex; + gap: 8px; + 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 new file mode 100644 index 00000000000000..9d8d7298333fe4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDefault.stories.tsx @@ -0,0 +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 { 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 Default = (): React.ReactNode => ( +
+
Example with Tag:
+ + + Tag 1 + + + Tag 2 + + + Tag 3 + + + +
Example with InteractionTag:
+ + + Tag 1 + + + Tag 2 + + + Tag 3 + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/TagGroupDescription.md new file mode 100644 index 00000000000000..d6f970cd407cb5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/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/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/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 new file mode 100644 index 00000000000000..44ae930fe618c8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/index.stories.tsx @@ -0,0 +1,19 @@ +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 default { + title: 'Components/Tags/TagGroup', + component: TagGroup, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; 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 new file mode 100644 index 00000000000000..37db40626d9bf0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/Tags/TagGroup/tagGroup.module.css @@ -0,0 +1,51 @@ +.group { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; +} + +.group[data-disabled] { + opacity: 0.55; +} + +.tag { + composes: tag from '../Tag/tag.module.css'; +} + +.interactionTag { + composes: interactionTag from '../InteractionTag/interactionTag.module.css'; +} + +.primary { + composes: primary from '../InteractionTag/interactionTag.module.css'; +} + +.secondary { + composes: secondary from '../InteractionTag/interactionTag.module.css'; +} + +.dismissIcon { + composes: dismissIcon from '../Tag/tag.module.css'; +} + +.demo { + display: flex; + flex-direction: column; + gap: 16px; + align-items: flex-start; +} + +.label { + font-size: 12px; + color: var(--text-muted); +} + +.resetButton { + composes: resetButton from '../Tag/tag.module.css'; +} + +.selectedReadout { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono, monospace); +}