-
Notifications
You must be signed in to change notification settings - Fork 36
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 />); | ||
}); | ||
}); |
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; | ||
}); | ||
|
||
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' | ||
); | ||
} | ||
} | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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') { | ||
ling1726 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
el.style.marginInlineStart = tokens.spacingHorizontalS; | ||
return; | ||
} | ||
|
||
if (prev && el.dataset.appearance !== 'transparent') { | ||
prev.style.marginInlineStart = tokens.spacingHorizontalS; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As discussed offline - will try out registerProperty API without inheritable There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would double check if we really want to reuse |
||
|
||
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} | ||
/> | ||
); | ||
}); |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is too wild, let's be explicit |
There was a problem hiding this comment.
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