diff --git a/change/@fluentui-contrib-teams-components-29fd49f3-75f7-4481-8b4b-b4cd75278283.json b/change/@fluentui-contrib-teams-components-29fd49f3-75f7-4481-8b4b-b4cd75278283.json new file mode 100644 index 00000000..02231e7c --- /dev/null +++ b/change/@fluentui-contrib-teams-components-29fd49f3-75f7-4481-8b4b-b4cd75278283.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Added SplitButton teams component", + "packageName": "@fluentui-contrib/teams-components", + "email": "patrycja.fogelman@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/teams-components/jest.config.ts b/packages/teams-components/jest.config.ts index 3e1c3f76..3139b631 100644 --- a/packages/teams-components/jest.config.ts +++ b/packages/teams-components/jest.config.ts @@ -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', }; diff --git a/packages/teams-components/package.json b/packages/teams-components/package.json index e25a59cb..8efb5da9 100644 --- a/packages/teams-components/package.json +++ b/packages/teams-components/package.json @@ -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", diff --git a/packages/teams-components/src/components/SplitButton/SplitButton.test.tsx b/packages/teams-components/src/components/SplitButton/SplitButton.test.tsx new file mode 100644 index 00000000..5d330045 --- /dev/null +++ b/packages/teams-components/src/components/SplitButton/SplitButton.test.tsx @@ -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(Test); + }); + + it('should throw error if menuTitle is provided without main title', () => { + expect(() => + render(Test) + ).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()).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(} />)).toThrow( + '@fluentui-contrib/teams-components::Icon button must have a title or aria label' + ); + }); +}); diff --git a/packages/teams-components/src/components/SplitButton/SplitButton.tsx b/packages/teams-components/src/components/SplitButton/SplitButton.tsx new file mode 100644 index 00000000..e2aa0858 --- /dev/null +++ b/packages/teams-components/src/components/SplitButton/SplitButton.tsx @@ -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 +) => { + 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 ( + + {(triggerProps: MenuButtonProps) => + useTeamsSplitButton(props, triggerProps, ref) + } + + ); +}) as ForwardRefComponent; + +SplitButton.displayName = 'SplitButton'; + +const validateProps = (props: SplitButtonProps) => { + validateHasContent(props); + validateStrictClasses(props.className); + validateSplitIconButton(props); + validateMenuButton(props); + validateTitleProps(props); +}; diff --git a/packages/teams-components/src/components/SplitButton/index.ts b/packages/teams-components/src/components/SplitButton/index.ts new file mode 100644 index 00000000..8a6a36d7 --- /dev/null +++ b/packages/teams-components/src/components/SplitButton/index.ts @@ -0,0 +1,3 @@ +export * from './SplitButton'; +export * from './validateProps'; +export * from './renderSplitButton'; diff --git a/packages/teams-components/src/components/SplitButton/renderSplitButton.tsx b/packages/teams-components/src/components/SplitButton/renderSplitButton.tsx new file mode 100644 index 00000000..32ed7392 --- /dev/null +++ b/packages/teams-components/src/components/SplitButton/renderSplitButton.tsx @@ -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; + menuTitle?: NonNullable; +} + +/** + * Renders a SplitButton component by passing the state defined props to the appropriate slots. + */ +export const renderSplitButton_unstable = ( + state: SplitButtonState, + titleProps: SplitButtonTitleProps +) => { + assertSlots(state); + + // rendering without tootlip if title is not defined + if (titleProps.title === undefined) { + return ( + + {state.primaryActionButton && } + {state.menuButton && } + + ); + } + + // if both title and menuTitle are defined, render separate tooltips for each button + if (titleProps.menuTitle !== undefined) { + return ( + + {state.primaryActionButton && + renderTooltip(, titleProps.title)} + {state.menuButton && + renderTooltip(, titleProps.menuTitle)} + + ); + } + + // render single tooltip for the whole split button + return renderTooltip( + + {state.primaryActionButton && } + {state.menuButton && } + , + titleProps.title + ); +}; diff --git a/packages/teams-components/src/components/SplitButton/renderTooltip.tsx b/packages/teams-components/src/components/SplitButton/renderTooltip.tsx new file mode 100644 index 00000000..783661f5 --- /dev/null +++ b/packages/teams-components/src/components/SplitButton/renderTooltip.tsx @@ -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 +) => { + return ( + + {children} + + ); +}; diff --git a/packages/teams-components/src/components/SplitButton/validateProps.ts b/packages/teams-components/src/components/SplitButton/validateProps.ts new file mode 100644 index 00000000..d436c84f --- /dev/null +++ b/packages/teams-components/src/components/SplitButton/validateProps.ts @@ -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' + ); + } +}; diff --git a/packages/teams-components/src/index.ts b/packages/teams-components/src/index.ts index 0d8982ff..e31d5815 100644 --- a/packages/teams-components/src/index.ts +++ b/packages/teams-components/src/index.ts @@ -3,6 +3,7 @@ export { ToggleButton, type ToggleButtonProps, } from './components/ToggleButton'; +export { SplitButton } from './components/SplitButton'; export { makeStrictStyles, mergeStrictClasses, diff --git a/packages/teams-components/stories/SplitButton/Default.stories.tsx b/packages/teams-components/stories/SplitButton/Default.stories.tsx new file mode 100644 index 00000000..63a1f571 --- /dev/null +++ b/packages/teams-components/stories/SplitButton/Default.stories.tsx @@ -0,0 +1,240 @@ +import * as React from 'react'; +import { SplitButton } from '@fluentui-contrib/teams-components'; +import { + makeStyles, + tokens, + Menu, + MenuPopover, + MenuList, + MenuItem, +} from '@fluentui/react-components'; +import { + CalendarRegular, + CalendarFilled, + bundleIcon, +} from '@fluentui/react-icons'; + +const CalendarIcon = bundleIcon(CalendarFilled, CalendarRegular); + +const useStyles = makeStyles({ + sampleContainer: { + display: 'grid', + gridTemplateColumns: 'repeat(5, 120px)', + gap: tokens.spacingHorizontalL, + }, + + evil: { + background: 'red', + }, +}); + +export const Default = () => { + const styles = useStyles(); + return ( +
+ + Split + + + + Item a + Item b + + + + + Split + + + + Item a + Item b + + + + + + Split + + + + + Item a + Item b + + + + + } + > + Split + + + + + Item a + Item b + + + + + } + /> + + + + Item a + Item b + + + + + + Split + + + + Item a + Item b + + + + + + Split + + + + + Item a + Item b + + + + + + Split + + + + + Item a + Item b + + + + + } + appearance="transparent" + > + Split + + + + + Item a + Item b + + + + + } + appearance="transparent" + /> + + + + Item a + Item b + + + + + Split + + + + Item a + Item b + + + + + + Split + + + + + Item a + Item b + + + + + + Split + + + + + Item a + Item b + + + + + } + appearance="primary" + > + Split + + + + + Item a + Item b + + + + + } + appearance="primary" + /> + + + + Item a + Item b + + + +
+ ); +}; diff --git a/packages/teams-components/stories/SplitButton/index.stories.tsx b/packages/teams-components/stories/SplitButton/index.stories.tsx new file mode 100644 index 00000000..bbd89c3b --- /dev/null +++ b/packages/teams-components/stories/SplitButton/index.stories.tsx @@ -0,0 +1,10 @@ +import type { Meta } from '@storybook/react'; +import { SplitButton } from '@fluentui-contrib/teams-components'; +export { Default } from './Default.stories'; + +const meta = { + title: 'Packages/teams-components/SplitButton', + component: SplitButton, +} satisfies Meta; + +export default meta; diff --git a/yarn.lock b/yarn.lock index 3d251f05..26c73a44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2413,10 +2413,10 @@ "@griffel/react" "^1.5.22" "@swc/helpers" "^0.5.1" -"@fluentui/react-shared-contexts@^9.23.1", "@fluentui/react-shared-contexts@^9.7.2": - version "9.23.1" - resolved "https://registry.yarnpkg.com/@fluentui/react-shared-contexts/-/react-shared-contexts-9.23.1.tgz#96155b604574c2207d1100727d477f5ab6e6e36d" - integrity sha512-mP+7talxLz7n0G36o7Asdvst+JPzUbqbnoMKUWRVB5YwzlOXumEgaQDgL1BkRUJYaDGOjIiSTUjHOEkBt7iSdg== +"@fluentui/react-shared-contexts@>=9.7.2 <10.0.0", "@fluentui/react-shared-contexts@^9.23.1", "@fluentui/react-shared-contexts@^9.7.2": + version "9.24.0" + resolved "https://registry.yarnpkg.com/@fluentui/react-shared-contexts/-/react-shared-contexts-9.24.0.tgz#33cf16ee3f2736e9f3a194680ee23533039e90a4" + integrity sha512-GA+uLv711E+YGrAP/aVB15ozvNCiuB2ZrPDC9aYF+A6sRDxoZZG8VgHjhQ/YWJfVjDXLky4ihirknzsW1sjGtg== dependencies: "@fluentui/react-theme" "^9.1.24" "@swc/helpers" "^0.5.1"