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.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",
Expand Down
15 changes: 12 additions & 3 deletions packages/teams-components/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -34,9 +34,18 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement, ButtonProps>(
export interface MenuButtonProps extends ButtonProps {
menuIcon?: StrictSlot;
}

export const MenuButton = React.forwardRef<HTMLButtonElement, MenuButtonProps>(
(userProps, ref) => {
if (process.env.NODE_ENV !== 'production') {
validateProps(userProps);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { makeStyles } from '@fluentui/react-components';

export const useStyles = makeStyles({
root: {
display: 'flex',
},
});
Original file line number Diff line number Diff line change
@@ -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(<Toolbar />);
});
});
168 changes: 168 additions & 0 deletions packages/teams-components/src/components/Toolbar/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, ToolbarProps>(
(props, ref) => {
const { children, className } = props;
const styles = useStyles();
const enforceSpacingRef = useEnforceItemSpacing();

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

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;
});
Copy link
Member

Choose a reason for hiding this comment

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

TreeWalker is nice, but why? element.children with a filter will do the same


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'
);
}
}
};
Copy link
Member

Choose a reason for hiding this comment

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

We will definitely need a nicer validator


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;
Copy link
Member

Choose a reason for hiding this comment

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

Have to say, that this should be somehow declarative as it will go will quite quickly.

Dumb idea:

const STYLE_SCHEMAS = {
  kind: 'ToolbarButton',
  prevKind: 'ToolbarDivider',
  nextKind: null,
  meta: { appearance },
  style: { marginInlineStart: tokens.spacingHorizontalS },
}

function applyStylesToElement() {}

Copy link
Member Author

Choose a reason for hiding this comment

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

As discussed offline - will try out registerProperty API without inheritable

Copy link
Member Author

Choose a reason for hiding this comment

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

we could try leverage default react context or other means to make sure the cost of property registration only happens with usage

return;
}
};
Original file line number Diff line number Diff line change
@@ -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<ButtonProps, 'className'>;

const rootStrictClassName = createStrictClass(toolbarButtonClassNames.root);

// TODO teams-components should reuse composition patterns
export const ToolbarButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
return (
<Button
ref={ref}
{...props}
className={rootStrictClassName}
data-appearance={props.appearance}
/>
);
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { makeStyles } from '@fluentui/react-components';

export const useStyles = makeStyles({
root: {
flexGrow: 'unset',
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as React from 'react';
import {
useDividerStyles_unstable,
useDivider_unstable,
renderDivider_unstable,
mergeClasses,
} from '@fluentui/react-components';
import { useStyles } from './ToolbarDivider.styles';

export const toolbarDividerClassNames = {
root: 'tco-ToolbarDivider',
};

export const ToolbarDivider = React.forwardRef<
HTMLDivElement,
Record<string, never>
>((props, ref) => {
const styles = useStyles();
const state = useDivider_unstable(
{
...props,
vertical: true,
className: mergeClasses(toolbarDividerClassNames.root, styles.root),
},
ref
);
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


return renderDivider_unstable(state);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as React from 'react';
import { MenuButton, MenuButtonProps } from '../MenuButton';
import { createStrictClass } from '../../strictStyles/createStrictClass';

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

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

const rootStrictClassName = createStrictClass(toolbarMenuButtonClassNames.root);

// TODO teams-components should reuse composition patterns
export const ToolbarMenuButton = React.forwardRef<
HTMLButtonElement,
MenuButtonProps
>((props, ref) => {
return (
<MenuButton
ref={ref}
{...props}
menuIcon={null}
className={rootStrictClassName}
data-appearance={props.appearance}
/>
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react';
import { ToggleButton, type ToggleButtonProps } from '../ToggleButton';
import { createStrictClass } from '../../strictStyles/createStrictClass';

export const toolbarToggleButtonClassNames = {
root: 'tco-ToolbarToggleButton',
};

export type ToolbarToggleButtonProps = Omit<ToggleButtonProps, 'className'>;

const rootStrictClassName = createStrictClass(
toolbarToggleButtonClassNames.root
);

// TODO teams-components should reuse composition patterns
export const ToolbarToggleButton = React.forwardRef<
HTMLButtonElement,
ToggleButtonProps
>((props, ref) => {
return (
<ToggleButton
ref={ref}
{...props}
className={rootStrictClassName}
data-appearance={props.appearance}
/>
);
});
5 changes: 5 additions & 0 deletions packages/teams-components/src/components/Toolbar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './Toolbar';
export * from './ToolbarDivider';
export * from './ToolbarButton';
export * from './ToolbarToggleButton';
export * from './ToolbarMenuButton';
Comment on lines +1 to +5
Copy link
Member

Choose a reason for hiding this comment

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

This is too wild, let's be explicit

Loading