Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<BadgeProps>;
Expand All @@ -25,11 +30,49 @@ export type BadgeState = BadgeBaseState & {
};
};

export { renderBadge }
// @public
export const CounterBadge: ForwardRefComponent<CounterBadgeProps>;

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<PresenceBadgeProps>;

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<HTMLDivElement>) => BadgeState;

// @public
export const useCounterBadge: (props: CounterBadgeProps, ref: React_2.Ref<HTMLDivElement>) => CounterBadgeState;

// @public
export const usePresenceBadge: (props: PresenceBadgeProps, ref: React_2.Ref<HTMLDivElement>) => PresenceBadgeState;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<CounterBadge data-testid="counter" />);

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(<CounterBadge count={0} showZero data-testid="counter" />);

expect(screen.getByTestId('counter')).toHaveTextContent('0');
expect(screen.getByTestId('counter')).not.toHaveAttribute('data-hidden');
});

it('does not hide an explicit zero child', () => {
render(<CounterBadge data-testid="counter">{0}</CounterBadge>);

expect(screen.getByTestId('counter')).toHaveTextContent('0');
expect(screen.getByTestId('counter')).not.toHaveAttribute('data-hidden');
});

it('renders a dot without count content', () => {
render(<CounterBadge count={4} dot data-testid="counter" />);

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(<CounterBadge count={100} overflowCount={99} data-testid="counter" />);

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(
<CounterBadge count={2} data-count="99" data-dot="" data-hidden="" data-overflowed="" data-testid="counter" />,
);

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');
});
});
Original file line number Diff line number Diff line change
@@ -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<CounterBadgeProps> = React.forwardRef((props, ref) => {
const state = useCounterBadge(props, ref);

return renderCounterBadge(state);
});

CounterBadge.displayName = 'CounterBadge';
Original file line number Diff line number Diff line change
@@ -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;
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { CounterBadge } from './CounterBadge';
export { renderCounterBadge } from './renderCounterBadge';
export { useCounterBadge } from './useCounterBadge';
export type { CounterBadgeProps, CounterBadgeState } from './CounterBadge.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { renderBadge_unstable as renderCounterBadge } from '@fluentui/react-badge';
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>): 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),
},
};
};
Original file line number Diff line number Diff line change
@@ -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<PresenceBadgeStatus, string> = {
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(<PresenceBadge status={status} data-testid="presence" />);

expect(screen.getByRole('img', { name: label })).toHaveAttribute('data-status', status);
},
);

it('combines out-of-office with the current status', () => {
render(<PresenceBadge status="busy" outOfOffice data-testid="presence" />);

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(<PresenceBadge status="out-of-office" outOfOffice />);

expect(screen.getByRole('img', { name: 'out of office' })).toBeInTheDocument();
});

it('does not allow reserved data attributes to misrepresent state', () => {
render(<PresenceBadge status="away" data-status="busy" data-out-of-office="" data-testid="presence" />);

expect(screen.getByTestId('presence')).toHaveAttribute('data-status', 'away');
expect(screen.getByTestId('presence')).not.toHaveAttribute('data-out-of-office');
});
});
Original file line number Diff line number Diff line change
@@ -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<PresenceBadgeProps> = React.forwardRef((props, ref) => {
const state = usePresenceBadge(props, ref);

return renderPresenceBadge(state);
});

PresenceBadge.displayName = 'PresenceBadge';
Original file line number Diff line number Diff line change
@@ -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;
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { PresenceBadge } from './PresenceBadge';
export { renderPresenceBadge } from './renderPresenceBadge';
export { usePresenceBadge } from './usePresenceBadge';
export type { PresenceBadgeProps, PresenceBadgeState, PresenceBadgeStatus } from './PresenceBadge.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { renderBadge_unstable as renderPresenceBadge } from '@fluentui/react-badge';
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>): PresenceBadgeState => {
const state = usePresenceBadgeBase_unstable(props, ref);

return {
...state,
root: {
...state.root,
'data-status': state.status,
'data-out-of-office': toDataAttributeValue(state.outOfOffice),
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading