diff --git a/change/@fluentui-react-headless-components-preview-3f13119d-36b2-4af8-8dd6-bdfac92e2d2a.json b/change/@fluentui-react-headless-components-preview-3f13119d-36b2-4af8-8dd6-bdfac92e2d2a.json new file mode 100644 index 00000000000000..dab9064f5aac66 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-3f13119d-36b2-4af8-8dd6-bdfac92e2d2a.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add headless CounterBadge and PresenceBadge", + "packageName": "@fluentui/react-headless-components-preview", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/etc/badge.api.md b/packages/react-components/react-headless-components-preview/library/etc/badge.api.md index 69ca4cacea6faf..7249b6839caf8a 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/badge.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/badge.api.md @@ -7,9 +7,14 @@ import type { BadgeBaseState } from '@fluentui/react-badge'; import { BadgeBaseProps as BadgeProps } from '@fluentui/react-badge'; import { BadgeSlots } from '@fluentui/react-badge'; +import type { CounterBadgeBaseState } from '@fluentui/react-badge'; +import { CounterBadgeBaseProps as CounterBadgeProps } from '@fluentui/react-badge'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { PresenceBadgeBaseState } from '@fluentui/react-badge'; +import { PresenceBadgeBaseProps as PresenceBadgeProps } from '@fluentui/react-badge'; +import { PresenceBadgeStatus } from '@fluentui/react-badge'; import type * as React_2 from 'react'; -import { renderBadge_unstable as renderBadge } from '@fluentui/react-badge'; +import { renderBadge_unstable } from '@fluentui/react-badge'; // @public export const Badge: ForwardRefComponent; @@ -25,11 +30,49 @@ export type BadgeState = BadgeBaseState & { }; }; -export { renderBadge } +// @public +export const CounterBadge: ForwardRefComponent; + +export { CounterBadgeProps } + +// @public +export type CounterBadgeState = CounterBadgeBaseState & { + root: { + 'data-count': string; + 'data-dot'?: string; + 'data-hidden'?: string; + 'data-overflowed'?: string; + }; +}; + +// @public +export const PresenceBadge: ForwardRefComponent; + +export { PresenceBadgeProps } + +// @public +export type PresenceBadgeState = PresenceBadgeBaseState & { + root: { + 'data-status': PresenceBadgeBaseState['status']; + 'data-out-of-office'?: string; + }; +}; + +export { PresenceBadgeStatus } + +export { renderBadge_unstable as renderBadge } +export { renderBadge_unstable as renderCounterBadge } +export { renderBadge_unstable as renderPresenceBadge } // @public export const useBadge: (props: BadgeProps, ref: React_2.Ref) => BadgeState; +// @public +export const useCounterBadge: (props: CounterBadgeProps, ref: React_2.Ref) => CounterBadgeState; + +// @public +export const usePresenceBadge: (props: PresenceBadgeProps, ref: React_2.Ref) => PresenceBadgeState; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/react-headless-components-preview/library/src/badge.ts b/packages/react-components/react-headless-components-preview/library/src/badge.ts index f9c6f871a73bb3..b4faf4e21eb4f3 100644 --- a/packages/react-components/react-headless-components-preview/library/src/badge.ts +++ b/packages/react-components/react-headless-components-preview/library/src/badge.ts @@ -1,2 +1,10 @@ -export { Badge, renderBadge, useBadge } from './components/Badge'; -export type { BadgeSlots, BadgeProps, BadgeState } from './components/Badge'; +export { Badge, renderBadge, useBadge } from './components/Badge/Badge/index'; +export type { BadgeProps, BadgeSlots, BadgeState } from './components/Badge/Badge/index'; +export { CounterBadge, renderCounterBadge, useCounterBadge } from './components/Badge/CounterBadge/index'; +export type { CounterBadgeProps, CounterBadgeState } from './components/Badge/CounterBadge/index'; +export { PresenceBadge, renderPresenceBadge, usePresenceBadge } from './components/Badge/PresenceBadge/index'; +export type { + PresenceBadgeProps, + PresenceBadgeState, + PresenceBadgeStatus, +} from './components/Badge/PresenceBadge/index'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/Badge.test.tsx similarity index 90% rename from packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge.test.tsx rename to packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/Badge.test.tsx index f58eb382f80700..142753a358c4dc 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/Badge.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render } from '@testing-library/react'; -import { isConformant } from '../../testing/isConformant'; -import { Badge } from './Badge'; +import { isConformant } from '../../../testing/isConformant'; +import { Badge } from '.'; describe('Badge', () => { isConformant({ diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/Badge.tsx similarity index 100% rename from packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge.tsx rename to packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/Badge.tsx diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/Badge.types.ts similarity index 100% rename from packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge.types.ts rename to packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/Badge.types.ts diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/index.ts similarity index 100% rename from packages/react-components/react-headless-components-preview/library/src/components/Badge/index.ts rename to packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/index.ts diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/renderBadge.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/renderBadge.tsx similarity index 100% rename from packages/react-components/react-headless-components-preview/library/src/components/Badge/renderBadge.tsx rename to packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/renderBadge.tsx diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/useBadge.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/useBadge.ts similarity index 100% rename from packages/react-components/react-headless-components-preview/library/src/components/Badge/useBadge.ts rename to packages/react-components/react-headless-components-preview/library/src/components/Badge/Badge/useBadge.ts diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.test.tsx new file mode 100644 index 00000000000000..12ec28dc4b25c8 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.test.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { isConformant } from '../../../testing/isConformant'; +import { CounterBadge } from './CounterBadge'; + +describe('CounterBadge', () => { + isConformant({ + Component: CounterBadge, + displayName: 'CounterBadge', + disabledTests: ['has-top-level-file-extra'], + }); + + it('hides an empty counter by default', () => { + render(); + + expect(screen.getByTestId('counter')).toHaveAttribute('data-count', '0'); + expect(screen.getByTestId('counter')).toHaveAttribute('data-hidden', ''); + expect(screen.getByTestId('counter')).not.toHaveAttribute('data-dot'); + }); + + it('renders zero when showZero is set', () => { + render(); + + expect(screen.getByTestId('counter')).toHaveTextContent('0'); + expect(screen.getByTestId('counter')).not.toHaveAttribute('data-hidden'); + }); + + it('does not hide an explicit zero child', () => { + render({0}); + + expect(screen.getByTestId('counter')).toHaveTextContent('0'); + expect(screen.getByTestId('counter')).not.toHaveAttribute('data-hidden'); + }); + + it('renders a dot without count content', () => { + render(); + + expect(screen.getByTestId('counter')).toHaveAttribute('data-dot', ''); + expect(screen.getByTestId('counter')).not.toHaveAttribute('data-hidden'); + expect(screen.getByTestId('counter')).toBeEmptyDOMElement(); + }); + + it('formats and marks an overflowing count', () => { + render(); + + expect(screen.getByTestId('counter')).toHaveTextContent('99+'); + expect(screen.getByTestId('counter')).toHaveAttribute('data-count', '100'); + expect(screen.getByTestId('counter')).toHaveAttribute('data-overflowed', ''); + }); + + it('does not allow reserved data attributes to misrepresent state', () => { + render( + , + ); + + expect(screen.getByTestId('counter')).toHaveAttribute('data-count', '2'); + expect(screen.getByTestId('counter')).not.toHaveAttribute('data-dot'); + expect(screen.getByTestId('counter')).not.toHaveAttribute('data-hidden'); + expect(screen.getByTestId('counter')).not.toHaveAttribute('data-overflowed'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.tsx new file mode 100644 index 00000000000000..c8054cd38194f9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { CounterBadgeProps } from './CounterBadge.types'; +import { renderCounterBadge } from './renderCounterBadge'; +import { useCounterBadge } from './useCounterBadge'; + +/** + * A headless badge for displaying a count or dot. + */ +export const CounterBadge: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useCounterBadge(props, ref); + + return renderCounterBadge(state); +}); + +CounterBadge.displayName = 'CounterBadge'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.types.ts new file mode 100644 index 00000000000000..c904d6c639b680 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/CounterBadge.types.ts @@ -0,0 +1,30 @@ +import type { CounterBadgeBaseState } from '@fluentui/react-badge'; + +export type { BadgeSlots, CounterBadgeBaseProps as CounterBadgeProps } from '@fluentui/react-badge'; + +/** + * CounterBadge component state. + */ +export type CounterBadgeState = CounterBadgeBaseState & { + root: { + /** + * The numeric count before overflow formatting. + */ + 'data-count': string; + + /** + * Present when the badge renders as a dot. + */ + 'data-dot'?: string; + + /** + * Present when the badge has no visible count or dot. + */ + 'data-hidden'?: string; + + /** + * Present when the count exceeds the configured overflow count. + */ + 'data-overflowed'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/index.ts new file mode 100644 index 00000000000000..d9c3104c5d2d7e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/index.ts @@ -0,0 +1,4 @@ +export { CounterBadge } from './CounterBadge'; +export { renderCounterBadge } from './renderCounterBadge'; +export { useCounterBadge } from './useCounterBadge'; +export type { CounterBadgeProps, CounterBadgeState } from './CounterBadge.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/renderCounterBadge.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/renderCounterBadge.ts new file mode 100644 index 00000000000000..0c660a0e272909 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/renderCounterBadge.ts @@ -0,0 +1 @@ +export { renderBadge_unstable as renderCounterBadge } from '@fluentui/react-badge'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/useCounterBadge.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/useCounterBadge.ts new file mode 100644 index 00000000000000..1914854d4785ff --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/CounterBadge/useCounterBadge.ts @@ -0,0 +1,27 @@ +'use client'; + +import type * as React from 'react'; +import { useCounterBadgeBase_unstable } from '@fluentui/react-badge'; + +import { toDataAttributeValue } from '../../../utils'; +import type { CounterBadgeProps, CounterBadgeState } from './CounterBadge.types'; + +/** + * Returns the state for a CounterBadge component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderCounterBadge`. + */ +export const useCounterBadge = (props: CounterBadgeProps, ref: React.Ref): CounterBadgeState => { + const state = useCounterBadgeBase_unstable(props, ref); + const overflowCount = props.overflowCount ?? 99; + + return { + ...state, + root: { + ...state.root, + 'data-count': String(state.count), + 'data-dot': toDataAttributeValue(state.dot), + 'data-hidden': toDataAttributeValue(!state.root.children && state.root.children !== 0 && !state.dot), + 'data-overflowed': toDataAttributeValue(state.count > overflowCount), + }, + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.test.tsx new file mode 100644 index 00000000000000..62fcf5f9849f55 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.test.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { isConformant } from '../../../testing/isConformant'; +import type { PresenceBadgeStatus } from './PresenceBadge.types'; +import { PresenceBadge } from './PresenceBadge'; + +const statusLabels: Record = { + available: 'available', + away: 'away', + blocked: 'blocked', + busy: 'busy', + 'do-not-disturb': 'do not disturb', + offline: 'offline', + 'out-of-office': 'out of office', + unknown: 'unknown', +}; + +describe('PresenceBadge', () => { + isConformant({ + Component: PresenceBadge, + displayName: 'PresenceBadge', + disabledTests: ['has-top-level-file-extra'], + }); + + it.each(Object.entries(statusLabels) as [PresenceBadgeStatus, string][])( + 'renders the %s status with an accessible name', + (status, label) => { + render(); + + expect(screen.getByRole('img', { name: label })).toHaveAttribute('data-status', status); + }, + ); + + it('combines out-of-office with the current status', () => { + render(); + + expect(screen.getByRole('img', { name: 'busy out of office' })).toHaveAttribute('data-out-of-office', ''); + }); + + it('does not duplicate the out-of-office accessible name', () => { + render(); + + expect(screen.getByRole('img', { name: 'out of office' })).toBeInTheDocument(); + }); + + it('does not allow reserved data attributes to misrepresent state', () => { + render(); + + expect(screen.getByTestId('presence')).toHaveAttribute('data-status', 'away'); + expect(screen.getByTestId('presence')).not.toHaveAttribute('data-out-of-office'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.tsx new file mode 100644 index 00000000000000..539bffa5890244 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +import type { PresenceBadgeProps } from './PresenceBadge.types'; +import { renderPresenceBadge } from './renderPresenceBadge'; +import { usePresenceBadge } from './usePresenceBadge'; + +/** + * A headless badge that exposes presence status to assistive technology. + */ +export const PresenceBadge: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = usePresenceBadge(props, ref); + + return renderPresenceBadge(state); +}); + +PresenceBadge.displayName = 'PresenceBadge'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.types.ts new file mode 100644 index 00000000000000..1a89adec3f5992 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/PresenceBadge.types.ts @@ -0,0 +1,24 @@ +import type { PresenceBadgeBaseState } from '@fluentui/react-badge'; + +export type { + BadgeSlots, + PresenceBadgeBaseProps as PresenceBadgeProps, + PresenceBadgeStatus, +} from '@fluentui/react-badge'; + +/** + * PresenceBadge component state. + */ +export type PresenceBadgeState = PresenceBadgeBaseState & { + root: { + /** + * The current presence status. + */ + 'data-status': PresenceBadgeBaseState['status']; + + /** + * Present when out-of-office status is combined with the current presence. + */ + 'data-out-of-office'?: string; + }; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/index.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/index.ts new file mode 100644 index 00000000000000..d33d7f580fbea0 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/index.ts @@ -0,0 +1,4 @@ +export { PresenceBadge } from './PresenceBadge'; +export { renderPresenceBadge } from './renderPresenceBadge'; +export { usePresenceBadge } from './usePresenceBadge'; +export type { PresenceBadgeProps, PresenceBadgeState, PresenceBadgeStatus } from './PresenceBadge.types'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/renderPresenceBadge.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/renderPresenceBadge.ts new file mode 100644 index 00000000000000..e0670e3c6f7065 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/renderPresenceBadge.ts @@ -0,0 +1 @@ +export { renderBadge_unstable as renderPresenceBadge } from '@fluentui/react-badge'; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/usePresenceBadge.ts b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/usePresenceBadge.ts new file mode 100644 index 00000000000000..aea9c111adf6ee --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Badge/PresenceBadge/usePresenceBadge.ts @@ -0,0 +1,24 @@ +'use client'; + +import type * as React from 'react'; +import { usePresenceBadgeBase_unstable } from '@fluentui/react-badge'; + +import { toDataAttributeValue } from '../../../utils'; +import type { PresenceBadgeProps, PresenceBadgeState } from './PresenceBadge.types'; + +/** + * Returns the state for a PresenceBadge component, given its props and ref. + * The returned state can be modified with hooks before being passed to `renderPresenceBadge`. + */ +export const usePresenceBadge = (props: PresenceBadgeProps, ref: React.Ref): PresenceBadgeState => { + const state = usePresenceBadgeBase_unstable(props, ref); + + return { + ...state, + root: { + ...state.root, + 'data-status': state.status, + 'data-out-of-office': toDataAttributeValue(state.outOfOffice), + }, + }; +}; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/tokens.css b/packages/react-components/react-headless-components-preview/stories/.storybook/tokens.css index 24a88d06ceb8bc..0f42864dfab8ed 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/tokens.css +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/tokens.css @@ -94,6 +94,7 @@ --font-sans: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --font-display: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; + --font-weight-semibold: 600; --tracking-display: -0.03em; --tracking-heading: -0.02em; diff --git a/packages/react-components/react-headless-components-preview/stories/README.md b/packages/react-components/react-headless-components-preview/stories/README.md index 003e448e761c8d..48fffabba3d2dc 100644 --- a/packages/react-components/react-headless-components-preview/stories/README.md +++ b/packages/react-components/react-headless-components-preview/stories/README.md @@ -105,7 +105,7 @@ finds. | Radius | `--radius-xs` 4 px → `--radius-3xl` 24 px, `--radius-pill` 999 px | | Stroke | `--stroke-thin/thick/thicker` (1 / 2 / 3 px) | | Spacing | `--space-1` … `--space-16` on a 4 px grid | -| Type | `--font-sans` (Segoe UI), `--font-mono`, `--font-display` | +| Type | `--font-sans` (Segoe UI), `--font-mono`, `--font-display`, `--font-weight-semibold` | | Motion | `--ease-standard`, `--ease-emphasized`, `--duration-fast/medium/slow` | Read the file directly when in doubt: `.storybook/tokens.css`. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDefault.stories.tsx index 8f6715cd7655c6..6b916f07faedc6 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDefault.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDefault.stories.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Badge } from '@fluentui/react-headless-components-preview/badge'; import styles from './badge.module.css'; + export const Default = (): React.ReactNode => (
Default diff --git a/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDescription.md b/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDescription.md index a1e39ca5696fee..5b12ed2d2264ea 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDescription.md +++ b/packages/react-components/react-headless-components-preview/stories/src/Badge/BadgeDescription.md @@ -1,3 +1,3 @@ -A badge is a small visual indicator used to display a status, count, or category label. +A badge is a small, non-interactive visual indicator used to display a status, count, or category label. Use badges to communicate short, scannable information such as notification counts on an icon, pipeline statuses in a list, or user role labels on a profile. Keep badge text extremely concise — ideally one word or a number. diff --git a/packages/react-components/react-headless-components-preview/stories/src/Badge/badge.module.css b/packages/react-components/react-headless-components-preview/stories/src/Badge/badge.module.css index 2ffe77fef34ee5..69333592b9d9ff 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Badge/badge.module.css +++ b/packages/react-components/react-headless-components-preview/stories/src/Badge/badge.module.css @@ -1,16 +1,13 @@ .badge { display: inline-flex; align-items: center; - gap: 4px; - height: 22px; - padding: 0 8px; + gap: var(--space-1); + min-block-size: var(--space-6); + padding-inline: var(--space-2); border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--text); - border: 1px solid var(--border); - font-size: 11.5px; - font-weight: 500; - letter-spacing: 0; + border: var(--stroke-thin) solid var(--border); } .solid { @@ -32,8 +29,8 @@ } .danger { - background: var(--brand-soft); - color: var(--brand); + background: var(--danger-soft); + color: var(--danger); border-color: transparent; } @@ -50,37 +47,31 @@ } .dot { - width: 6px; - height: 6px; + inline-size: var(--space-2); + block-size: var(--space-2); border-radius: 50%; background: currentColor; display: inline-block; } -.icon { - width: 12px; - height: 12px; -} - .counter { - height: 18px; - min-width: 18px; - padding: 0 6px; - font-size: 10.5px; - font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + min-inline-size: var(--space-6); + min-block-size: var(--space-6); + padding-inline: var(--space-1); + border-radius: var(--radius-pill); background: var(--brand); - color: white; - border-color: transparent; + color: var(--text-on-accent); + font-weight: 600; } /* Demo helpers (used by Storybook examples) */ .demo { display: flex; - align-items: center; - - gap: 12px; - + gap: var(--space-3); flex-wrap: wrap; } diff --git a/packages/react-components/react-headless-components-preview/stories/src/Badge/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/Badge/index.stories.tsx index 0051f90d790eb2..f701e10147083e 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/Badge/index.stories.tsx +++ b/packages/react-components/react-headless-components-preview/stories/src/Badge/index.stories.tsx @@ -3,8 +3,10 @@ import { Badge } from '@fluentui/react-headless-components-preview/badge'; import descriptionMd from './BadgeDescription.md'; export { Default } from './BadgeDefault.stories'; +import './badge.module.css'; + export default { - title: 'Components/Badge', + title: 'Components/Badge/Badge', component: Badge, parameters: { docs: { diff --git a/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/CounterBadgeDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/CounterBadgeDefault.stories.tsx new file mode 100644 index 00000000000000..04dfd9be2d84ec --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/CounterBadgeDefault.stories.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { CounterBadge } from '@fluentui/react-headless-components-preview/badge'; + +import styles from './counter-badge.module.css'; + +export const Default = (): React.ReactNode => ( +
+ + + + + +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/CounterBadgeDescription.md b/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/CounterBadgeDescription.md new file mode 100644 index 00000000000000..b0147896cbd8ee --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/CounterBadgeDescription.md @@ -0,0 +1,3 @@ +A CounterBadge displays a numeric count or a dot-only notification state. + +It formats counts above `overflowCount`, renders zero only when `showZero` is set, and exposes `data-count`, `data-dot`, `data-hidden`, and `data-overflowed` for styling. Associate the count with its host control's accessible name. diff --git a/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/counter-badge.module.css b/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/counter-badge.module.css new file mode 100644 index 00000000000000..240ee9f54d106a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/counter-badge.module.css @@ -0,0 +1,37 @@ +.counterBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-inline-size: var(--space-6); + min-block-size: var(--space-6); + padding-inline: var(--space-1); + border-radius: var(--radius-pill); + background: var(--brand); + color: var(--text-on-accent); + font-weight: var(--font-weight-semibold); +} + +.counterBadge[data-dot] { + min-inline-size: var(--space-2); + min-block-size: var(--space-2); + padding: 0; +} + +.counterBadge[data-hidden] { + display: none; +} + +.notificationButton { + display: inline-flex; + padding: 0; + border: 0; + background: transparent; + font: inherit; +} + +.demo { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/index.stories.tsx new file mode 100644 index 00000000000000..70d83e8a8404a4 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/CounterBadge/index.stories.tsx @@ -0,0 +1,18 @@ +import { CounterBadge } from '@fluentui/react-headless-components-preview/badge'; + +import descriptionMd from './CounterBadgeDescription.md'; +export { Default } from './CounterBadgeDefault.stories'; + +import './counter-badge.module.css'; + +export default { + title: 'Components/Badge/CounterBadge', + component: CounterBadge, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeDefault.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeDefault.stories.tsx new file mode 100644 index 00000000000000..b1b0f888a113df --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeDefault.stories.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { PresenceBadge, type PresenceBadgeStatus } from '@fluentui/react-headless-components-preview/badge'; +import { + PresenceAvailableFilled, + PresenceAwayFilled, + PresenceBlockedRegular, + PresenceBusyFilled, + PresenceDndFilled, + PresenceOfflineRegular, + PresenceOofRegular, + PresenceUnknownRegular, +} from '@fluentui/react-icons'; + +import styles from './presence-badge.module.css'; + +const statusIcons: Record = { + available: , + away: , + blocked: , + busy: , + 'do-not-disturb': , + offline: , + 'out-of-office': , + unknown: , +}; + +export const Default = (): React.ReactNode => ( +
+ {(Object.keys(statusIcons) as PresenceBadgeStatus[]).map(status => ( + + ))} +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeDescription.md b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeDescription.md new file mode 100644 index 00000000000000..42df76af057928 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeDescription.md @@ -0,0 +1,3 @@ +A PresenceBadge communicates a person's availability through an accessible status image. + +Supply an `icon` for the visible indicator. The root exposes `data-status` and `data-out-of-office` for styling; the host experience must not rely on color alone to communicate presence. diff --git a/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeOutOfOffice.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeOutOfOffice.stories.tsx new file mode 100644 index 00000000000000..655a769f1b8d96 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/PresenceBadgeOutOfOffice.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { PresenceBadge, type PresenceBadgeStatus } from '@fluentui/react-headless-components-preview/badge'; +import { + PresenceAvailableRegular, + PresenceBlockedRegular, + PresenceDndRegular, + PresenceOofRegular, + PresenceUnknownRegular, +} from '@fluentui/react-icons'; + +import styles from './presence-badge.module.css'; + +const outOfOfficeIcons: Record = { + available: , + away: , + blocked: , + busy: , + 'do-not-disturb': , + offline: , + 'out-of-office': , + unknown: , +}; + +export const OutOfOffice = (): React.ReactNode => ( +
+ {(Object.keys(outOfOfficeIcons) as PresenceBadgeStatus[]).map(status => ( + + ))} +
+); diff --git a/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/index.stories.tsx b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/index.stories.tsx new file mode 100644 index 00000000000000..3c2fce5fafa164 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/index.stories.tsx @@ -0,0 +1,19 @@ +import { PresenceBadge } from '@fluentui/react-headless-components-preview/badge'; + +import descriptionMd from './PresenceBadgeDescription.md'; +export { Default } from './PresenceBadgeDefault.stories'; +export { OutOfOffice } from './PresenceBadgeOutOfOffice.stories'; + +import './presence-badge.module.css'; + +export default { + title: 'Components/Badge/PresenceBadge', + component: PresenceBadge, + parameters: { + docs: { + description: { + component: descriptionMd, + }, + }, + }, +}; diff --git a/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/presence-badge.module.css b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/presence-badge.module.css new file mode 100644 index 00000000000000..b4abe353f78640 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/src/PresenceBadge/presence-badge.module.css @@ -0,0 +1,36 @@ +.presenceBadge { + display: inline-flex; + border-radius: var(--radius-pill); + background: var(--bg-elev); + color: var(--text-soft); + overflow: hidden; +} + +.presenceIcon { + display: flex; +} + +.presenceBadge[data-status='available'] { + color: var(--success); +} + +.presenceBadge[data-status='away'] { + color: var(--warning); +} + +.presenceBadge[data-status='busy'], +.presenceBadge[data-status='do-not-disturb'], +.presenceBadge[data-status='blocked'] { + color: var(--danger); +} + +.presenceBadge[data-status='out-of-office'] { + color: var(--brand); +} + +.demo { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +}