Skip to content
Open
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": "patch",
"comment": "feat: add headless SplitButton",
"packageName": "@fluentui/react-headless-components-preview",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import * as Skeleton from '@fluentui/react-headless-components-preview/skeleton'
import * as Slider from '@fluentui/react-headless-components-preview/slider';
import * as SpinButton from '@fluentui/react-headless-components-preview/spin-button';
import * as Spinner from '@fluentui/react-headless-components-preview/spinner';
import * as SplitButton from '@fluentui/react-headless-components-preview/split-button';
import * as Switch from '@fluentui/react-headless-components-preview/switch';
import * as TabList from '@fluentui/react-headless-components-preview/tab-list';
import * as Tag from '@fluentui/react-headless-components-preview/tag';
Expand Down Expand Up @@ -87,6 +88,7 @@ console.log({
Slider,
SpinButton,
Spinner,
SplitButton,
Switch,
TabList,
Tag,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## 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 { ButtonBaseProps } from '@fluentui/react-button';
import type { ComponentProps } from '@fluentui/react-utilities';
import type { ComponentState } from '@fluentui/react-utilities';
import type { ForwardRefComponent } from '@fluentui/react-utilities';
import { MenuButtonBaseProps } from '@fluentui/react-button';
import type * as React_2 from 'react';
import { renderSplitButton_unstable as renderSplitButton } from '@fluentui/react-button';
import type { Slot } from '@fluentui/react-utilities';
import type { SplitButtonBaseProps } from '@fluentui/react-button';
import type { SplitButtonBaseSlots } from '@fluentui/react-button';
import type { SplitButtonBaseState } from '@fluentui/react-button';

export { renderSplitButton }

// @public
export const SplitButton: ForwardRefComponent<SplitButtonProps>;

// @public
export type SplitButtonProps = ComponentProps<SplitButtonSlots> & Pick<SplitButtonBaseProps, 'disabled' | 'disabledFocusable' | 'icon' | 'iconPosition' | 'menuIcon'>;

// @public (undocumented)
export type SplitButtonSlots = Omit<SplitButtonBaseSlots, 'menuButton' | 'primaryActionButton'> & {
menuButton?: Slot<typeof MenuButton>;
primaryActionButton?: Slot<typeof Button>;
};

// @public
export type SplitButtonState = ComponentState<SplitButtonSlots> & Pick<SplitButtonBaseState, 'disabled' | 'disabledFocusable' | 'iconPosition'>;

// @public
export const useSplitButton: (props: SplitButtonProps, ref: React_2.Ref<HTMLDivElement>) => SplitButtonState;

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,12 @@
"import": "./lib/spinner.js",
"require": "./lib-commonjs/spinner.js"
},
"./split-button": {
"types": "./dist/split-button.d.ts",
"node": "./lib-commonjs/split-button.js",
"import": "./lib/split-button.js",
"require": "./lib-commonjs/split-button.js"
},
"./switch": {
"types": "./dist/switch.d.ts",
"node": "./lib-commonjs/switch.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { SlotRenderFunction } from '@fluentui/react-utilities';
import { isConformant } from '../../testing/isConformant';
import { SplitButton } from './SplitButton';
import type { ButtonProps } from '../Button/Button.types';
import type { MenuButtonProps } from '../MenuButton/MenuButton.types';

describe('SplitButton', () => {
isConformant({
Component: SplitButton,
displayName: 'SplitButton',
});

it('renders both the primary action button and the menu button', () => {
const { getAllByRole } = render(<SplitButton>This is a button</SplitButton>);
const [primaryActionButton, menuButton] = getAllByRole('button');

expect(primaryActionButton).toBeInTheDocument();
expect(menuButton).toBeInTheDocument();
});

it('preserves default children for a primary action button render function', () => {
const renderPrimaryActionButton: SlotRenderFunction<ButtonProps> = (_Component, slotProps) => slotProps.children;
const { getByText } = render(
<SplitButton primaryActionButton={{ children: renderPrimaryActionButton }}>This is a button</SplitButton>,
);

expect(getByText('This is a button')).toBeInTheDocument();
});

it('preserves undefined children for a menu button render function', () => {
const renderMenuButton: SlotRenderFunction<MenuButtonProps> = (_Component, slotProps) => {
expect(slotProps.children).toBeUndefined();
return <button type="button" />;
};
const { getAllByRole } = render(
<SplitButton menuButton={{ children: renderMenuButton }}>This is a button</SplitButton>,
);

expect(getAllByRole('button')).toHaveLength(2);
});

it('ships no default menu icon', () => {
const { getAllByRole } = render(<SplitButton>This is a button</SplitButton>);
const [, menuButton] = getAllByRole('button');

expect(menuButton.querySelector('svg')).not.toBeInTheDocument();
});

it('renders a custom menuIcon slot', () => {
const { getAllByRole } = render(
<SplitButton menuIcon={<span data-testid="custom-menu-icon" />}>This is a button</SplitButton>,
);
const [, menuButton] = getAllByRole('button');

expect(menuButton.querySelector('[data-testid="custom-menu-icon"]')).toBeInTheDocument();
});

it('labels the menu button using the primary action button id by default', () => {
const { getAllByRole } = render(<SplitButton>This is a button</SplitButton>);
const [primaryActionButton, menuButton] = getAllByRole('button');

expect(menuButton).toHaveAttribute('aria-labelledby', primaryActionButton.id);
});

it('can trigger a function by clicking the primary action button', () => {
const onClick = jest.fn();
const { getAllByRole } = render(<SplitButton onClick={onClick}>This is a button</SplitButton>);
const [primaryActionButton] = getAllByRole('button');

userEvent.click(primaryActionButton);
expect(onClick).toHaveBeenCalled();
});

it('propagates disabled to both child buttons via their own data-disabled attribute', () => {
const { getAllByRole } = render(<SplitButton disabled>This is a button</SplitButton>);
const [primaryActionButton, menuButton] = getAllByRole('button');

expect(primaryActionButton).toHaveAttribute('data-disabled');
expect(menuButton).toHaveAttribute('data-disabled');
});

it('propagates disabledFocusable to both child buttons via their own data-disabled-focusable attribute', () => {
const { getAllByRole } = render(<SplitButton disabledFocusable>This is a button</SplitButton>);
const [primaryActionButton, menuButton] = getAllByRole('button');

expect(primaryActionButton).toHaveAttribute('data-disabled-focusable');
expect(menuButton).toHaveAttribute('data-disabled-focusable');
});

it('allows an independent primaryActionButton override to win over propagated defaults', () => {
const { getAllByRole } = render(
<SplitButton disabled primaryActionButton={{ disabled: false }}>
This is a button
</SplitButton>,
);
const [primaryActionButton, menuButton] = getAllByRole('button');

expect(primaryActionButton).not.toHaveAttribute('disabled');
expect(menuButton).toHaveAttribute('disabled');
});

it('allows an independent menuButton override to win over propagated defaults', () => {
const { getAllByRole } = render(
<SplitButton disabled menuButton={{ disabled: false }}>
This is a button
</SplitButton>,
);
const [primaryActionButton, menuButton] = getAllByRole('button');

expect(primaryActionButton).toHaveAttribute('disabled');
expect(menuButton).not.toHaveAttribute('disabled');
});

it('does not emit a wrapper-level data-disabled attribute', () => {
const { container } = render(<SplitButton disabled>This is a button</SplitButton>);
const root = container.firstElementChild as HTMLElement;

expect(root).not.toHaveAttribute('data-disabled');
});
});
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 { SplitButtonProps } from './SplitButton.types';
import { useSplitButton } from './useSplitButton';
import { renderSplitButton } from './renderSplitButton';

/**
* SplitButtons are a grouping of two interactive surfaces where interacting with the first one triggers a primary
* action, while interacting with the second one opens a menu with secondary actions.
*/
export const SplitButton: ForwardRefComponent<SplitButtonProps> = React.forwardRef((props, ref) => {
const state = useSplitButton(props, ref);

return renderSplitButton(state);
});

SplitButton.displayName = 'SplitButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { SplitButtonBaseProps, SplitButtonBaseSlots, SplitButtonBaseState } from '@fluentui/react-button';
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import type { Button } from '../Button/Button';
import type { MenuButton } from '../MenuButton/MenuButton';

export type SplitButtonSlots = Omit<SplitButtonBaseSlots, 'menuButton' | 'primaryActionButton'> & {
menuButton?: Slot<typeof MenuButton>;
primaryActionButton?: Slot<typeof Button>;
};

/**
* SplitButton props
*/
export type SplitButtonProps = ComponentProps<SplitButtonSlots> &
Pick<SplitButtonBaseProps, 'disabled' | 'disabledFocusable' | 'icon' | 'iconPosition' | 'menuIcon'>;

/**
* State used in rendering SplitButton
*/
export type SplitButtonState = ComponentState<SplitButtonSlots> &
Pick<SplitButtonBaseState, 'disabled' | 'disabledFocusable' | 'iconPosition'>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { SplitButton } from './SplitButton';
export { renderSplitButton } from './renderSplitButton';
export { useSplitButton } from './useSplitButton';
export type { SplitButtonSlots, SplitButtonProps, SplitButtonState } from './SplitButton.types';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { renderSplitButton_unstable as renderSplitButton } from '@fluentui/react-button';
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import type * as React from 'react';
import { slot } from '@fluentui/react-utilities';
import { useSplitButtonBase_unstable } from '@fluentui/react-button';
import { Button } from '../Button/Button';
import { MenuButton } from '../MenuButton/MenuButton';
import type { SplitButtonProps, SplitButtonState } from './SplitButton.types';

/**
* Returns the state for a SplitButton component, given its props and ref.
*
* Composes base behavior with headless Button and MenuButton slots without adding
* SplitButton-level styling or state attributes.
*/
export const useSplitButton = (props: SplitButtonProps, ref: React.Ref<HTMLDivElement>): SplitButtonState => {
const baseState = useSplitButtonBase_unstable(props, ref);

const menuButtonShorthand = slot.optional(props.menuButton, {
defaultProps: {
...baseState.menuButton,
children: undefined,
},
renderByDefault: true,
elementType: MenuButton,
});
const primaryActionButtonShorthand = slot.optional(props.primaryActionButton, {
defaultProps: {
...baseState.primaryActionButton,
children: props.children,
},
renderByDefault: true,
elementType: Button,
});

return {
...baseState,
components: { root: 'div', menuButton: MenuButton, primaryActionButton: Button },
menuButton: menuButtonShorthand,
primaryActionButton: primaryActionButtonShorthand,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { SplitButton, renderSplitButton, useSplitButton } from './components/SplitButton';
export type { SplitButtonSlots, SplitButtonProps, SplitButtonState } from './components/SplitButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as React from 'react';
import { Menu, MenuTrigger, MenuPopover, MenuList, MenuItem } from '@fluentui/react-headless-components-preview/menu';
import { SplitButton } from '@fluentui/react-headless-components-preview/split-button';
import type { MenuButtonProps } from '@fluentui/react-headless-components-preview/menu-button';
import { ChevronDownRegular } from '@fluentui/react-icons';

import styles from './split-button.module.css';

const onPrimaryActionClick = () => alert('Primary action clicked.');

export const Default = (): React.ReactNode => (
<Menu positioning="below-end">
<MenuTrigger disableButtonEnhancement>
{(triggerProps: MenuButtonProps) => (
<SplitButton
className={styles.wrapper}
primaryActionButton={{ className: styles.primaryButton, onClick: onPrimaryActionClick }}
menuButton={{ ...triggerProps, className: styles.menuButton }}
menuIcon={<ChevronDownRegular aria-hidden />}
>
Send
</SplitButton>
)}
</MenuTrigger>
<MenuPopover className={styles.surface}>
<MenuList className={styles.list}>
<MenuItem className={styles.item}>Send now</MenuItem>
<MenuItem className={styles.item}>Schedule send</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
A split button combines a primary action with a menu trigger for related actions.

The headless `SplitButton` has no default menu icon. Provide one through the `menuIcon` slot when
needed.

To compose with `Menu`, use `MenuTrigger`'s render prop to forward its generated trigger props to
the `menuButton` slot.

When no accessible name is provided for the menu trigger, `SplitButton` generates an
`aria-labelledby` fallback that references the primary action.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { SplitButton } from '@fluentui/react-headless-components-preview/split-button';

import descriptionMd from './SplitButtonDescription.md';

export { Default } from './SplitButtonDefault.stories';

export default {
title: 'Components/SplitButton',
component: SplitButton,
parameters: {
docs: {
description: {
component: descriptionMd,
},
},
},
};
Loading
Loading