Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Toolbar component to teams-components #311

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.58.3",
"@fluentui/react-components": "^9.59.0",
"@fluentui/react-icons": "^2.0.249",
"@fluentui/react-migration-v8-v9": "^9.6.23",
"@fluentui/react-shared-contexts": "^9.7.2",
Expand Down
201 changes: 60 additions & 141 deletions packages/teams-components/src/components/Toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
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';
import {
ToolbarItemRegistrationProvider,
useInitItemRegistration,
} from './itemRegistration';

export interface ToolbarProps {
children: React.ReactNode;
Expand All @@ -27,142 +23,65 @@ export const Toolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
(props, ref) => {
const { children, className } = props;
const styles = useStyles();
const enforceSpacingRef = useEnforceItemSpacing();
const registerItem = useInitItemRegistration();
const contextValue = React.useMemo(
() => ({ registerItem }),
[registerItem]
);

return (
<div
role="toolbar"
className={mergeClasses(
toolbarClassNames.root,
styles.root,
className?.toString()
)}
ref={useMergedRefs(ref, enforceSpacingRef)}
{...useArrowNavigationGroup({ axis: 'both', circular: true })}
>
{children}
</div>
<ToolbarItemRegistrationProvider value={contextValue}>
<div
role="toolbar"
className={mergeClasses(
toolbarClassNames.root,
styles.root,
className?.toString()
)}
ref={ref}
{...useArrowNavigationGroup({ axis: 'both', circular: true })}
>
{children}
</div>
</ToolbarItemRegistrationProvider>
);
}
);

const useEnforceItemSpacing = () => {
const elRef = React.useRef<HTMLDivElement | null>(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;
}
};
// TODO implement DOM validation API
// 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'
// );
// }
// }
// };
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as React from 'react';
import { Button, type ButtonProps } from '../Button';
import { createStrictClass } from '../../strictStyles/createStrictClass';
import { useItemRegistration } from './itemRegistration';
import { useMergedRefs } from '@fluentui/react-components';
import { mergeStrictClasses } from '../../strictStyles/mergeStrictClasses';

export const toolbarButtonClassNames = {
root: 'tco-ToolbarButton',
Expand All @@ -13,11 +16,16 @@ const rootStrictClassName = createStrictClass(toolbarButtonClassNames.root);
// TODO teams-components should reuse composition patterns
export const ToolbarButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const { ref: registerRef, styles } = useItemRegistration({
appearance: props.appearance,
type: 'button',
});

return (
<Button
ref={ref}
ref={useMergedRefs(ref, registerRef)}
{...props}
className={rootStrictClassName}
className={mergeStrictClasses(rootStrictClassName, styles.root)}
data-appearance={props.appearance}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
useDivider_unstable,
renderDivider_unstable,
mergeClasses,
useMergedRefs,
} from '@fluentui/react-components';
import { useStyles } from './ToolbarDivider.styles';
import { useItemRegistration } from './itemRegistration';

export const toolbarDividerClassNames = {
root: 'tco-ToolbarDivider',
Expand All @@ -16,13 +18,22 @@ export const ToolbarDivider = React.forwardRef<
Record<string, never>
>((props, ref) => {
const styles = useStyles();
const { ref: registerRef, styles: itemRegistrationStyles } =
useItemRegistration({
appearance: props.appearance,
type: 'divider',
});
const state = useDivider_unstable(
{
...props,
vertical: true,
className: mergeClasses(toolbarDividerClassNames.root, styles.root),
className: mergeClasses(
toolbarDividerClassNames.root,
styles.root,
itemRegistrationStyles.root.toString()
),
},
ref
useMergedRefs(ref, registerRef)
);
useDividerStyles_unstable(state);
Copy link
Member

Choose a reason for hiding this comment

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

I would double check if we really want to reuse Divider there


Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import * as React from 'react';
import { MenuButton, MenuButtonProps } from '../MenuButton';
import { createStrictClass } from '../../strictStyles/createStrictClass';
import { useItemRegistration } from './itemRegistration';
import { useMergedRefs } from '@fluentui/react-components';
import { mergeStrictClasses } from '../../strictStyles';

export const toolbarMenuButtonClassNames = {
root: 'tco-ToolbarMenuButton',
};

export type ToolbarMenuButtonProps = Omit<MenuButtonProps, 'className' | 'menuIcon'>;
export type ToolbarMenuButtonProps = Omit<
MenuButtonProps,
'className' | 'menuIcon'
>;

const rootStrictClassName = createStrictClass(toolbarMenuButtonClassNames.root);

Expand All @@ -15,12 +21,16 @@ export const ToolbarMenuButton = React.forwardRef<
HTMLButtonElement,
MenuButtonProps
>((props, ref) => {
const { ref: registerRef, styles } = useItemRegistration({
appearance: props.appearance,
type: 'button',
});
return (
<MenuButton
ref={ref}
ref={useMergedRefs(ref, registerRef)}
{...props}
menuIcon={null}
className={rootStrictClassName}
className={mergeStrictClasses(rootStrictClassName, styles.root)}
data-appearance={props.appearance}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as React from 'react';
import { ToggleButton, type ToggleButtonProps } from '../ToggleButton';
import { createStrictClass } from '../../strictStyles/createStrictClass';
import { useItemRegistration } from './itemRegistration';
import { useMergedRefs } from '@fluentui/react-components';
import { mergeStrictClasses } from '../../strictStyles';

export const toolbarToggleButtonClassNames = {
root: 'tco-ToolbarToggleButton',
Expand All @@ -17,11 +20,15 @@ export const ToolbarToggleButton = React.forwardRef<
HTMLButtonElement,
ToggleButtonProps
>((props, ref) => {
const { ref: registerRef, styles } = useItemRegistration({
appearance: props.appearance,
type: 'button',
});
return (
<ToggleButton
ref={ref}
ref={useMergedRefs(ref, registerRef)}
{...props}
className={rootStrictClassName}
className={mergeStrictClasses(rootStrictClassName, styles.root)}
data-appearance={props.appearance}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { makeStrictStyles } from '../../strictStyles/makeStrictStyles';

export const itemRegistrationVars = {
toolbarItemMarginInlineStart: '--toolbar-item-margin-inline-start',
};

let propertyRegisterComplete = false;

export const registerCustomProperties = (win: typeof globalThis) => {
if (propertyRegisterComplete) {
Comment on lines +7 to +10
Copy link
Member

Choose a reason for hiding this comment

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

Should be done per window?

return;
}

try {
win.CSS.registerProperty({
name: itemRegistrationVars.toolbarItemMarginInlineStart,
syntax: '<length>',
inherits: false,
initialValue: '0px',
});
} catch {
// ignore multiple registration error
}

propertyRegisterComplete = true;
};

export const useItemRegistrationStyles = makeStrictStyles({
root: {
marginInlineStart: `var(${itemRegistrationVars.toolbarItemMarginInlineStart})`,
},
});
Loading