Skip to content

Commit 9965712

Browse files
committed
feat: Add Toolbar component to teams-components
1 parent 418db77 commit 9965712

17 files changed

+1323
-687
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"@dnd-kit/modifiers": "^7.0.0",
3030
"@dnd-kit/utilities": "^3.2.2",
3131
"@fluentui/react": "^8.120.2",
32-
"@fluentui/react-components": "^9.54.13",
32+
"@fluentui/react-components": "^9.58.3",
3333
"@fluentui/react-icons": "^2.0.249",
3434
"@fluentui/react-migration-v8-v9": "^9.6.23",
3535
"@fluentui/react-shared-contexts": "^9.7.2",

packages/teams-components/src/components/Button/Button.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@fluentui/react-components';
99
import { validateIconButton, validateMenuButton } from './validateProps';
1010
import { type StrictCssClass, validateStrictClasses } from '../../strictStyles';
11-
import { type StrictSlot } from '../../strictSlot';
11+
import { type StrictSlot, DataAttributeProps } from '../../strictSlot';
1212

1313
export interface ButtonProps
1414
extends Pick<
@@ -34,9 +34,18 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
3434
validateProps(userProps);
3535
}
3636

37-
const { className, icon, title, ...restProps } = userProps;
38-
const props: ButtonPropsBase = {
37+
const {
38+
className,
39+
icon,
40+
title,
41+
appearance = 'secondary',
42+
...restProps
43+
} = userProps;
44+
45+
const props: ButtonPropsBase & DataAttributeProps = {
3946
...restProps,
47+
'data-appearance': appearance,
48+
appearance,
4049
className: className?.toString(),
4150
iconPosition: 'before',
4251
icon,

packages/teams-components/src/components/MenuButton/MenuButton.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ import {
88
} from '@fluentui/react-components';
99
import { ButtonProps, validateIconButton } from '../Button';
1010
import { validateStrictClasses } from '../../strictStyles';
11+
import { StrictSlot } from '../../strictSlot';
1112

12-
export const MenuButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
13+
export interface MenuButtonProps extends ButtonProps {
14+
menuIcon?: StrictSlot;
15+
}
16+
17+
export const MenuButton = React.forwardRef<HTMLButtonElement, MenuButtonProps>(
1318
(userProps, ref) => {
1419
if (process.env.NODE_ENV !== 'production') {
1520
validateProps(userProps);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { makeStyles } from '@fluentui/react-components';
2+
3+
export const useStyles = makeStyles({
4+
root: {
5+
display: 'flex',
6+
},
7+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as React from 'react';
2+
import { render } from '@testing-library/react';
3+
import { Toolbar } from './Toolbar';
4+
5+
describe('Toolbar', () => {
6+
it('should render', () => {
7+
render(<Toolbar />);
8+
});
9+
});
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import {
2+
tokens,
3+
useMergedRefs,
4+
mergeClasses,
5+
useArrowNavigationGroup,
6+
} from '@fluentui/react-components';
7+
import { isHTMLElement } from '@fluentui/react-utilities';
8+
import * as React from 'react';
9+
import { useStyles } from './Toolbar.styles';
10+
import { HTMLElementWalker } from '../../elementWalker';
11+
import { StrictCssClass } from '../../strictStyles';
12+
import { toolbarButtonClassNames } from './ToolbarButton';
13+
import { toolbarDividerClassNames } from './ToolbarDivider';
14+
import { toolbarToggleButtonClassNames } from './ToolbarToggleButton';
15+
import { toolbarMenuButtonClassNames } from './ToolbarMenuButton';
16+
17+
export interface ToolbarProps {
18+
children: React.ReactNode;
19+
className?: StrictCssClass;
20+
}
21+
22+
export const toolbarClassNames = {
23+
root: 'tco-Toolbar',
24+
};
25+
26+
export const Toolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
27+
(props, ref) => {
28+
const { children, className } = props;
29+
const styles = useStyles();
30+
const enforceSpacingRef = useEnforceItemSpacing();
31+
32+
return (
33+
<div
34+
role="toolbar"
35+
className={mergeClasses(
36+
toolbarClassNames.root,
37+
styles.root,
38+
className?.toString()
39+
)}
40+
ref={useMergedRefs(ref, enforceSpacingRef)}
41+
{...useArrowNavigationGroup({ axis: 'both', circular: true })}
42+
>
43+
{children}
44+
</div>
45+
);
46+
}
47+
);
48+
49+
const useEnforceItemSpacing = () => {
50+
const elRef = React.useRef<HTMLDivElement | null>(null);
51+
52+
React.useLayoutEffect(() => {
53+
if (!elRef.current?.ownerDocument.defaultView) {
54+
return;
55+
}
56+
57+
if (process.env.NODE_ENV !== 'production') {
58+
validateToolbarItems(elRef.current);
59+
}
60+
61+
const treeWalker = new HTMLElementWalker(elRef.current, (el) => {
62+
if (isAllowedToolbarItem(el) || el === treeWalker.root) {
63+
return NodeFilter.FILTER_ACCEPT;
64+
}
65+
66+
return NodeFilter.FILTER_REJECT;
67+
});
68+
69+
reaclcToolbarSpacing(treeWalker);
70+
71+
const mutationObserver =
72+
new elRef.current.ownerDocument.defaultView.MutationObserver(() => {
73+
if (!elRef.current) {
74+
return;
75+
}
76+
77+
if (process.env.NODE_ENV !== 'production') {
78+
validateToolbarItems(elRef.current);
79+
}
80+
81+
// TODO can optimize by only doing recalc of affected elements
82+
reaclcToolbarSpacing(treeWalker);
83+
});
84+
85+
mutationObserver.observe(elRef.current, {
86+
childList: true,
87+
});
88+
89+
return () => mutationObserver.disconnect();
90+
}, []);
91+
92+
return elRef;
93+
};
94+
95+
const reaclcToolbarSpacing = (treeWalker: HTMLElementWalker) => {
96+
treeWalker.currentElement = treeWalker.root;
97+
let current = treeWalker.firstChild();
98+
while (current) {
99+
recalcToolbarItemSpacing(current, treeWalker);
100+
101+
treeWalker.currentElement = current;
102+
current = treeWalker.nextElement();
103+
}
104+
};
105+
106+
const isAllowedToolbarItem = (el: HTMLElement) => {
107+
return (
108+
el.classList.contains(toolbarButtonClassNames.root) ||
109+
el.classList.contains(toolbarDividerClassNames.root) ||
110+
el.classList.contains(toolbarMenuButtonClassNames.root) ||
111+
el.classList.contains(toolbarToggleButtonClassNames.root)
112+
);
113+
};
114+
115+
const isPortalSpan = (el: HTMLElement) => {
116+
return el.tagName === 'SPAN' && el.hasAttribute('hidden');
117+
};
118+
119+
const isTabsterDummy = (el: HTMLElement) => {
120+
return el.hasAttribute('data-tabster-dummy');
121+
};
122+
123+
const validateToolbarItems = (root: HTMLElement) => {
124+
const children = root.children;
125+
for (const child of children) {
126+
// TODO is this even possible?
127+
if (!isHTMLElement(child)) {
128+
continue;
129+
}
130+
131+
if (
132+
!isAllowedToolbarItem(child) &&
133+
!isPortalSpan(child) &&
134+
!isTabsterDummy(child)
135+
) {
136+
throw new Error(
137+
'@fluentui-contrib/teams-components::Toolbar::Use Toolbar components from @fluentui-contrib/teams-components package only'
138+
);
139+
}
140+
}
141+
};
142+
143+
const recalcToolbarItemSpacing = (
144+
el: HTMLElement,
145+
treeWalker: HTMLElementWalker
146+
) => {
147+
treeWalker.currentElement = treeWalker.root;
148+
if (el === treeWalker.firstChild() || !isAllowedToolbarItem(el)) {
149+
return;
150+
}
151+
152+
if (el.classList.contains(toolbarDividerClassNames.root)) {
153+
el.style.marginInlineStart = tokens.spacingHorizontalS;
154+
return;
155+
}
156+
157+
treeWalker.currentElement = el;
158+
const prev = treeWalker.previousElement();
159+
if (prev && prev.dataset.appearance !== 'transparent') {
160+
el.style.marginInlineStart = tokens.spacingHorizontalS;
161+
return;
162+
}
163+
164+
if (prev && el.dataset.appearance !== 'transparent') {
165+
prev.style.marginInlineStart = tokens.spacingHorizontalS;
166+
return;
167+
}
168+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as React from 'react';
2+
import { Button, type ButtonProps } from '../Button';
3+
import { createStrictClass } from '../../strictStyles/createStrictClass';
4+
5+
export const toolbarButtonClassNames = {
6+
root: 'tco-ToolbarButton',
7+
};
8+
9+
export type ToolbarButtonProps = Omit<ButtonProps, 'className'>;
10+
11+
const rootStrictClassName = createStrictClass(toolbarButtonClassNames.root);
12+
13+
// TODO teams-components should reuse composition patterns
14+
export const ToolbarButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
15+
(props, ref) => {
16+
return (
17+
<Button
18+
ref={ref}
19+
{...props}
20+
className={rootStrictClassName}
21+
data-appearance={props.appearance}
22+
/>
23+
);
24+
}
25+
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { makeStyles } from '@fluentui/react-components';
2+
3+
export const useStyles = makeStyles({
4+
root: {
5+
flexGrow: 'unset',
6+
},
7+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as React from 'react';
2+
import {
3+
useDividerStyles_unstable,
4+
useDivider_unstable,
5+
renderDivider_unstable,
6+
mergeClasses,
7+
} from '@fluentui/react-components';
8+
import { useStyles } from './ToolbarDivider.styles';
9+
10+
export const toolbarDividerClassNames = {
11+
root: 'tco-ToolbarDivider',
12+
};
13+
14+
export const ToolbarDivider = React.forwardRef<
15+
HTMLDivElement,
16+
Record<string, never>
17+
>((props, ref) => {
18+
const styles = useStyles();
19+
const state = useDivider_unstable(
20+
{
21+
...props,
22+
vertical: true,
23+
className: mergeClasses(toolbarDividerClassNames.root, styles.root),
24+
},
25+
ref
26+
);
27+
useDividerStyles_unstable(state);
28+
29+
return renderDivider_unstable(state);
30+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as React from 'react';
2+
import { MenuButton, MenuButtonProps } from '../MenuButton';
3+
import { createStrictClass } from '../../strictStyles/createStrictClass';
4+
5+
export const toolbarMenuButtonClassNames = {
6+
root: 'tco-ToolbarMenuButton',
7+
};
8+
9+
export type ToolbarMenuButtonProps = Omit<MenuButtonProps, 'className' | 'menuIcon'>;
10+
11+
const rootStrictClassName = createStrictClass(toolbarMenuButtonClassNames.root);
12+
13+
// TODO teams-components should reuse composition patterns
14+
export const ToolbarMenuButton = React.forwardRef<
15+
HTMLButtonElement,
16+
MenuButtonProps
17+
>((props, ref) => {
18+
return (
19+
<MenuButton
20+
ref={ref}
21+
{...props}
22+
menuIcon={null}
23+
className={rootStrictClassName}
24+
data-appearance={props.appearance}
25+
/>
26+
);
27+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as React from 'react';
2+
import { ToggleButton, type ToggleButtonProps } from '../ToggleButton';
3+
import { createStrictClass } from '../../strictStyles/createStrictClass';
4+
5+
export const toolbarToggleButtonClassNames = {
6+
root: 'tco-ToolbarToggleButton',
7+
};
8+
9+
export type ToolbarToggleButtonProps = Omit<ToggleButtonProps, 'className'>;
10+
11+
const rootStrictClassName = createStrictClass(
12+
toolbarToggleButtonClassNames.root
13+
);
14+
15+
// TODO teams-components should reuse composition patterns
16+
export const ToolbarToggleButton = React.forwardRef<
17+
HTMLButtonElement,
18+
ToggleButtonProps
19+
>((props, ref) => {
20+
return (
21+
<ToggleButton
22+
ref={ref}
23+
{...props}
24+
className={rootStrictClassName}
25+
data-appearance={props.appearance}
26+
/>
27+
);
28+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './Toolbar';
2+
export * from './ToolbarDivider';
3+
export * from './ToolbarButton';
4+
export * from './ToolbarToggleButton';
5+
export * from './ToolbarMenuButton';

0 commit comments

Comments
 (0)