Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Added SplitButton teams component",
"packageName": "@fluentui-contrib/teams-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
2 changes: 1 addition & 1 deletion packages/teams-components/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default {
transform: {
'^.+\\.[tj]sx?$': ['@swc/jest', swcJestConfig],
},
moduleFileExtensions: ['ts', 'js', 'html'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'html'],
testEnvironment: 'jsdom',
coverageDirectory: '../../coverage/packages/teams-components',
};
5 changes: 4 additions & 1 deletion packages/teams-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"name": "@fluentui-contrib/teams-components",
"version": "0.1.2",
"dependencies": {
"@swc/helpers": "~0.5.11"
"@swc/helpers": "~0.5.11",
"@fluentui/react-utilities": "^9.16.0",
"@fluentui/react-shared-contexts": ">=9.7.2 <10.0.0",
"@fluentui/react-jsx-runtime": "^9.0.29"
},
"main": "./src/index.js",
"typings": "./src/index.d.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { SplitButton } from './SplitButton';
import { CalendarRegular } from '@fluentui/react-icons';

describe('SplitButton', () => {
it('should render', () => {
render(<SplitButton>Test</SplitButton>);
});

it('should throw error if menuTitle is provided without main title', () => {
expect(() =>
render(<SplitButton menuTitle="Menu">Test</SplitButton>)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this requirement, why would menu title make the title required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If title is provided without menu title, tooltip will be applied to the full SplitButton
If both are given, there will be separate tooltip for action button and menu button - so I think it makes no sense to be able to provide only menu title

).toThrow(
'@fluentui-contrib/teams-components::SplitButton with menuTitle present, title must also be provided'
);
});

it('should throw error if no content or icon is provided', () => {
expect(() => render(<SplitButton />)).toThrow(
'@fluentui-contrib/teams-components::SplitButton must have at least one of children or icon'
);
});

it('should throw error if icon button has no title provided', () => {
expect(() => render(<SplitButton icon={<CalendarRegular />} />)).toThrow(
'@fluentui-contrib/teams-components::Icon button must have a title or aria label'
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as React from 'react';
import {
renderSplitButton_unstable,
SplitButtonTitleProps,
} from './renderSplitButton';
import type { SplitButtonProps as SplitButtonPropsBase } from '@fluentui/react-components';
import {
useSplitButton_unstable,
useSplitButtonStyles_unstable,
renderSplitButton_unstable as renderSplitButtonBase_unstable,
MenuTrigger,
MenuButtonProps,
} from '@fluentui/react-components';
import { ForwardRefComponent } from '@fluentui/react-utilities';
import { useCustomStyleHook_unstable } from '@fluentui/react-shared-contexts';
import { validateStrictClasses } from '../../strictStyles';
import { StrictSlot } from '../../strictSlot';
import { ButtonProps, validateMenuButton } from '../Button';
import {
validateTitleProps,
validateSplitIconButton,
validateHasContent,
} from './validateProps';

export interface SplitButtonProps extends ButtonProps {
menuTitle?: StrictSlot;
}

const useTeamsSplitButton = (
props: SplitButtonProps,
triggerProps: MenuButtonProps,
ref: React.ForwardedRef<HTMLButtonElement>
) => {
const { className, icon, title, menuTitle, ...restProps } = props;
const buttonProps = {
...restProps,
...triggerProps,
className: className?.toString(),
icon,
} as SplitButtonPropsBase;
const state = useSplitButton_unstable(buttonProps, ref);

useSplitButtonStyles_unstable(state);

useCustomStyleHook_unstable('useSplitButtonStyles_unstable')(state);

const titleProps: SplitButtonTitleProps = {
title: title ?? undefined,
menuTitle: menuTitle ?? undefined,
};

return titleProps.title
? renderSplitButton_unstable(state, titleProps)
: renderSplitButtonBase_unstable(state);
};

export const SplitButton = React.forwardRef<
HTMLButtonElement,
SplitButtonProps
>((props, ref) => {
if (process.env.NODE_ENV !== 'production') {
validateProps(props);
}

return (
<MenuTrigger>
{(triggerProps: MenuButtonProps) =>
useTeamsSplitButton(props, triggerProps, ref)
}
</MenuTrigger>
);
}) as ForwardRefComponent<SplitButtonProps>;

SplitButton.displayName = 'SplitButton';

const validateProps = (props: SplitButtonProps) => {
validateHasContent(props);
validateStrictClasses(props.className);
validateSplitIconButton(props);
validateMenuButton(props);
validateTitleProps(props);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './SplitButton';
export * from './validateProps';
export * from './renderSplitButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** @jsxRuntime classic */
/** @jsx createElement */

// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { createElement } from '@fluentui/react-jsx-runtime';

import { assertSlots } from '@fluentui/react-utilities';
import type {
SplitButtonSlots,
SplitButtonState,
} from '@fluentui/react-components';
import { StrictSlot } from '../../strictSlot';
import { renderTooltip } from './renderTooltip';

export interface SplitButtonTitleProps {
title?: NonNullable<StrictSlot>;
menuTitle?: NonNullable<StrictSlot>;
}

/**
* Renders a SplitButton component by passing the state defined props to the appropriate slots.
*/
export const renderSplitButton_unstable = (
state: SplitButtonState,
titleProps: SplitButtonTitleProps
) => {
assertSlots<SplitButtonSlots>(state);

// rendering without tootlip if title is not defined
if (titleProps.title === undefined) {
return (
<state.root>
{state.primaryActionButton && <state.primaryActionButton />}
{state.menuButton && <state.menuButton />}
</state.root>
);
}

// if both title and menuTitle are defined, render separate tooltips for each button
if (titleProps.menuTitle !== undefined) {
return (
<state.root>
{state.primaryActionButton &&
renderTooltip(<state.primaryActionButton />, titleProps.title)}
{state.menuButton &&
renderTooltip(<state.menuButton />, titleProps.menuTitle)}
</state.root>
);
}

// render single tooltip for the whole split button
return renderTooltip(
<state.root>
{state.primaryActionButton && <state.primaryActionButton />}
{state.menuButton && <state.menuButton />}
</state.root>,
titleProps.title
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as React from 'react';
import { Tooltip, TooltipProps } from '@fluentui/react-components';
import { StrictSlot } from '../../strictSlot';

export const renderTooltip = (
children: TooltipProps['children'],
title: NonNullable<StrictSlot>
) => {
return (
<Tooltip content={title} relationship="label">
{children}
</Tooltip>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const validateTitleProps = (props: {
title?: unknown;
menuTitle?: unknown;
}) => {
if (props.menuTitle && !props.title) {
throw new Error(
'@fluentui-contrib/teams-components::SplitButton with menuTitle present, title must also be provided'
);
}
};

export const validateSplitIconButton = (props: {
title?: unknown;
icon?: unknown;
children?: unknown;
'aria-label'?: string;
'aria-labelledby'?: string;
}) => {
if (
!props.children &&
props.icon &&
!props.title &&
!(props['aria-label'] || props['aria-labelledby'])
) {
throw new Error(
'@fluentui-contrib/teams-components::Icon button must have a title or aria label'
);
}
};

export const validateHasContent = (props: {
children?: unknown;
icon?: unknown;
}) => {
if (!props.children && !props.icon) {
throw new Error(
'@fluentui-contrib/teams-components::SplitButton must have at least one of children or icon'
);
}
};
1 change: 1 addition & 0 deletions packages/teams-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {
ToggleButton,
type ToggleButtonProps,
} from './components/ToggleButton';
export { SplitButton } from './components/SplitButton';
export {
makeStrictStyles,
mergeStrictClasses,
Expand Down
Loading
Loading