From 99657120d9ba09b746d34c1a31f592ade7e5b27a Mon Sep 17 00:00:00 2001 From: Lingfan Gao Date: Mon, 17 Feb 2025 19:35:43 +0100 Subject: [PATCH 1/3] feat: Add Toolbar component to teams-components --- package.json | 2 +- .../src/components/Button/Button.tsx | 15 +- .../src/components/MenuButton/MenuButton.tsx | 7 +- .../src/components/Toolbar/Toolbar.styles.ts | 7 + .../src/components/Toolbar/Toolbar.test.tsx | 9 + .../src/components/Toolbar/Toolbar.tsx | 168 ++ .../src/components/Toolbar/ToolbarButton.tsx | 25 + .../Toolbar/ToolbarDivider.styles.ts | 7 + .../src/components/Toolbar/ToolbarDivider.tsx | 30 + .../components/Toolbar/ToolbarMenuButton.tsx | 27 + .../Toolbar/ToolbarToggleButton.tsx | 28 + .../src/components/Toolbar/index.ts | 5 + .../teams-components/src/elementWalker.ts | 70 + packages/teams-components/src/index.ts | 13 +- .../stories/Toolbar/Default.stories.tsx | 184 +++ .../stories/Toolbar/index.stories.tsx | 10 + yarn.lock | 1403 +++++++++-------- 17 files changed, 1323 insertions(+), 687 deletions(-) create mode 100644 packages/teams-components/src/components/Toolbar/Toolbar.styles.ts create mode 100644 packages/teams-components/src/components/Toolbar/Toolbar.test.tsx create mode 100644 packages/teams-components/src/components/Toolbar/Toolbar.tsx create mode 100644 packages/teams-components/src/components/Toolbar/ToolbarButton.tsx create mode 100644 packages/teams-components/src/components/Toolbar/ToolbarDivider.styles.ts create mode 100644 packages/teams-components/src/components/Toolbar/ToolbarDivider.tsx create mode 100644 packages/teams-components/src/components/Toolbar/ToolbarMenuButton.tsx create mode 100644 packages/teams-components/src/components/Toolbar/ToolbarToggleButton.tsx create mode 100644 packages/teams-components/src/components/Toolbar/index.ts create mode 100644 packages/teams-components/src/elementWalker.ts create mode 100644 packages/teams-components/stories/Toolbar/Default.stories.tsx create mode 100644 packages/teams-components/stories/Toolbar/index.stories.tsx diff --git a/package.json b/package.json index 717102e2..a0272172 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/utilities": "^3.2.2", "@fluentui/react": "^8.120.2", - "@fluentui/react-components": "^9.54.13", + "@fluentui/react-components": "^9.58.3", "@fluentui/react-icons": "^2.0.249", "@fluentui/react-migration-v8-v9": "^9.6.23", "@fluentui/react-shared-contexts": "^9.7.2", diff --git a/packages/teams-components/src/components/Button/Button.tsx b/packages/teams-components/src/components/Button/Button.tsx index bbd39cd0..76b9ea8a 100644 --- a/packages/teams-components/src/components/Button/Button.tsx +++ b/packages/teams-components/src/components/Button/Button.tsx @@ -8,7 +8,7 @@ import { } from '@fluentui/react-components'; import { validateIconButton, validateMenuButton } from './validateProps'; import { type StrictCssClass, validateStrictClasses } from '../../strictStyles'; -import { type StrictSlot } from '../../strictSlot'; +import { type StrictSlot, DataAttributeProps } from '../../strictSlot'; export interface ButtonProps extends Pick< @@ -34,9 +34,18 @@ export const Button = React.forwardRef( validateProps(userProps); } - const { className, icon, title, ...restProps } = userProps; - const props: ButtonPropsBase = { + const { + className, + icon, + title, + appearance = 'secondary', + ...restProps + } = userProps; + + const props: ButtonPropsBase & DataAttributeProps = { ...restProps, + 'data-appearance': appearance, + appearance, className: className?.toString(), iconPosition: 'before', icon, diff --git a/packages/teams-components/src/components/MenuButton/MenuButton.tsx b/packages/teams-components/src/components/MenuButton/MenuButton.tsx index d2909570..4537c034 100644 --- a/packages/teams-components/src/components/MenuButton/MenuButton.tsx +++ b/packages/teams-components/src/components/MenuButton/MenuButton.tsx @@ -8,8 +8,13 @@ import { } from '@fluentui/react-components'; import { ButtonProps, validateIconButton } from '../Button'; import { validateStrictClasses } from '../../strictStyles'; +import { StrictSlot } from '../../strictSlot'; -export const MenuButton = React.forwardRef( +export interface MenuButtonProps extends ButtonProps { + menuIcon?: StrictSlot; +} + +export const MenuButton = React.forwardRef( (userProps, ref) => { if (process.env.NODE_ENV !== 'production') { validateProps(userProps); diff --git a/packages/teams-components/src/components/Toolbar/Toolbar.styles.ts b/packages/teams-components/src/components/Toolbar/Toolbar.styles.ts new file mode 100644 index 00000000..a4442eb6 --- /dev/null +++ b/packages/teams-components/src/components/Toolbar/Toolbar.styles.ts @@ -0,0 +1,7 @@ +import { makeStyles } from '@fluentui/react-components'; + +export const useStyles = makeStyles({ + root: { + display: 'flex', + }, +}); diff --git a/packages/teams-components/src/components/Toolbar/Toolbar.test.tsx b/packages/teams-components/src/components/Toolbar/Toolbar.test.tsx new file mode 100644 index 00000000..eadacbda --- /dev/null +++ b/packages/teams-components/src/components/Toolbar/Toolbar.test.tsx @@ -0,0 +1,9 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { Toolbar } from './Toolbar'; + +describe('Toolbar', () => { + it('should render', () => { + render(); + }); +}); diff --git a/packages/teams-components/src/components/Toolbar/Toolbar.tsx b/packages/teams-components/src/components/Toolbar/Toolbar.tsx new file mode 100644 index 00000000..d6686721 --- /dev/null +++ b/packages/teams-components/src/components/Toolbar/Toolbar.tsx @@ -0,0 +1,168 @@ +import { + tokens, + useMergedRefs, + mergeClasses, + useArrowNavigationGroup, +} from '@fluentui/react-components'; +import { isHTMLElement } from '@fluentui/react-utilities'; +import * as React from 'react'; +import { useStyles } from './Toolbar.styles'; +import { HTMLElementWalker } from '../../elementWalker'; +import { StrictCssClass } from '../../strictStyles'; +import { toolbarButtonClassNames } from './ToolbarButton'; +import { toolbarDividerClassNames } from './ToolbarDivider'; +import { toolbarToggleButtonClassNames } from './ToolbarToggleButton'; +import { toolbarMenuButtonClassNames } from './ToolbarMenuButton'; + +export interface ToolbarProps { + children: React.ReactNode; + className?: StrictCssClass; +} + +export const toolbarClassNames = { + root: 'tco-Toolbar', +}; + +export const Toolbar = React.forwardRef( + (props, ref) => { + const { children, className } = props; + const styles = useStyles(); + const enforceSpacingRef = useEnforceItemSpacing(); + + return ( +
+ {children} +
+ ); + } +); + +const useEnforceItemSpacing = () => { + const elRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (!elRef.current?.ownerDocument.defaultView) { + return; + } + + if (process.env.NODE_ENV !== 'production') { + validateToolbarItems(elRef.current); + } + + const treeWalker = new HTMLElementWalker(elRef.current, (el) => { + if (isAllowedToolbarItem(el) || el === treeWalker.root) { + return NodeFilter.FILTER_ACCEPT; + } + + return NodeFilter.FILTER_REJECT; + }); + + reaclcToolbarSpacing(treeWalker); + + const mutationObserver = + new elRef.current.ownerDocument.defaultView.MutationObserver(() => { + if (!elRef.current) { + return; + } + + if (process.env.NODE_ENV !== 'production') { + validateToolbarItems(elRef.current); + } + + // TODO can optimize by only doing recalc of affected elements + reaclcToolbarSpacing(treeWalker); + }); + + mutationObserver.observe(elRef.current, { + childList: true, + }); + + return () => mutationObserver.disconnect(); + }, []); + + return elRef; +}; + +const reaclcToolbarSpacing = (treeWalker: HTMLElementWalker) => { + treeWalker.currentElement = treeWalker.root; + let current = treeWalker.firstChild(); + while (current) { + recalcToolbarItemSpacing(current, treeWalker); + + treeWalker.currentElement = current; + current = treeWalker.nextElement(); + } +}; + +const isAllowedToolbarItem = (el: HTMLElement) => { + return ( + el.classList.contains(toolbarButtonClassNames.root) || + el.classList.contains(toolbarDividerClassNames.root) || + el.classList.contains(toolbarMenuButtonClassNames.root) || + el.classList.contains(toolbarToggleButtonClassNames.root) + ); +}; + +const isPortalSpan = (el: HTMLElement) => { + return el.tagName === 'SPAN' && el.hasAttribute('hidden'); +}; + +const isTabsterDummy = (el: HTMLElement) => { + return el.hasAttribute('data-tabster-dummy'); +}; + +const validateToolbarItems = (root: HTMLElement) => { + const children = root.children; + for (const child of children) { + // TODO is this even possible? + if (!isHTMLElement(child)) { + continue; + } + + if ( + !isAllowedToolbarItem(child) && + !isPortalSpan(child) && + !isTabsterDummy(child) + ) { + throw new Error( + '@fluentui-contrib/teams-components::Toolbar::Use Toolbar components from @fluentui-contrib/teams-components package only' + ); + } + } +}; + +const recalcToolbarItemSpacing = ( + el: HTMLElement, + treeWalker: HTMLElementWalker +) => { + treeWalker.currentElement = treeWalker.root; + if (el === treeWalker.firstChild() || !isAllowedToolbarItem(el)) { + return; + } + + if (el.classList.contains(toolbarDividerClassNames.root)) { + el.style.marginInlineStart = tokens.spacingHorizontalS; + return; + } + + treeWalker.currentElement = el; + const prev = treeWalker.previousElement(); + if (prev && prev.dataset.appearance !== 'transparent') { + el.style.marginInlineStart = tokens.spacingHorizontalS; + return; + } + + if (prev && el.dataset.appearance !== 'transparent') { + prev.style.marginInlineStart = tokens.spacingHorizontalS; + return; + } +}; diff --git a/packages/teams-components/src/components/Toolbar/ToolbarButton.tsx b/packages/teams-components/src/components/Toolbar/ToolbarButton.tsx new file mode 100644 index 00000000..e38bc713 --- /dev/null +++ b/packages/teams-components/src/components/Toolbar/ToolbarButton.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Button, type ButtonProps } from '../Button'; +import { createStrictClass } from '../../strictStyles/createStrictClass'; + +export const toolbarButtonClassNames = { + root: 'tco-ToolbarButton', +}; + +export type ToolbarButtonProps = Omit; + +const rootStrictClassName = createStrictClass(toolbarButtonClassNames.root); + +// TODO teams-components should reuse composition patterns +export const ToolbarButton = React.forwardRef( + (props, ref) => { + return ( +