Skip to content

Commit

Permalink
refactor(default-layout): add ToolCollapseMenu component with updat…
Browse files Browse the repository at this point in the history
…ed `CollapseMenu` + `StatusButton`
  • Loading branch information
hermanwikner authored and bjoerge committed Sep 17, 2021
1 parent 63d87c3 commit d8cd710
Show file tree
Hide file tree
Showing 9 changed files with 356 additions and 2 deletions.
2 changes: 0 additions & 2 deletions packages/@sanity/default-layout/src/navbar/Navbar.tsx
Expand Up @@ -22,8 +22,6 @@ interface Props {
createMenuIsOpen: boolean
documentTypes: string[]
onCreateButtonClick: () => void
onSetLoginStatusElement: (element: HTMLDivElement) => void
onSwitchTool: () => void
onToggleMenu: () => void
onUserLogout: () => void
router: Router
Expand Down
@@ -0,0 +1,41 @@
import {Button, Card, Text, ButtonProps, ButtonTone, ThemeColorToneKey} from '@sanity/ui'
import React from 'react'
import styled, {css} from 'styled-components'

interface Props extends Omit<ButtonProps, 'text' | 'padding' | 'iconRight'> {
statusTone?: ButtonTone
}

const Root = styled(Button)`
position: relative;
`

const Dot = styled(Card)<{$toneKey?: ThemeColorToneKey}>`
position: absolute;
top: 7px;
right: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
border: 1px solid var(--card-bg-color);
${({$toneKey}) =>
$toneKey &&
css`
background: ${({theme}) => theme.sanity.color.selectable[$toneKey].selected.bg};
`}
`

export const StatusButton = React.forwardRef(function StatusButton(
props: Props & React.HTMLProps<HTMLButtonElement>,
ref: React.Ref<HTMLButtonElement>
) {
const Icon = props?.icon as any

return (
<Root {...props} icon={null} ref={ref}>
{Icon && <Text>{<Icon />}</Text>}
{props?.statusTone && <Dot $toneKey={props?.statusTone} scheme="dark" />}
</Root>
)
})
@@ -0,0 +1,230 @@
import React, {
useState,
useEffect,
useCallback,
useMemo,
ReactElement,
Children,
cloneElement,
} from 'react'
import {
Box,
Button,
Flex,
Menu,
MenuButton,
MenuItem,
Text,
Tooltip,
useElementRect,
ThemeColorSchemeKey,
} from '@sanity/ui'
import {InView} from 'react-intersection-observer'
import {EllipsisVerticalIcon} from '@sanity/icons'
import styled from 'styled-components'
import {CollapseMenuDivider, CollapseMenuButton} from '.'

interface CollapseMenuProps {
children: React.ReactNode
gap?: number | number[]
menuButton?: ReactElement<HTMLButtonElement>
menuScheme?: ThemeColorSchemeKey
onMenuVisible?: (visible: boolean) => void
}

const Root = styled(Box)<{$hide?: boolean}>`
border-radius: inherit;
overflow: hidden;
position: relative;
white-space: nowrap;
width: 100%;
padding: 0.25rem;
margin: -0.25rem;
`

const Inner = styled(Flex)<{$hide?: boolean}>`
inset: 0;
pointer-events: ${({$hide}) => ($hide ? 'none' : 'inherit')};
opacity: ${({$hide}) => ($hide ? 0 : 1)};
position: ${({$hide}) => ($hide ? 'absolute' : 'static')};
visibility: ${({$hide}) => ($hide ? 'hidden' : 'visible')};
width: ${({$hide}) => ($hide ? 'max-content' : 'auto')};
`

const OptionBox = styled(Box)<{$inView: boolean}>`
display: flex;
flex-shrink: 0;
list-style: none;
white-space: nowrap;
visibility: ${({$inView}) => ($inView ? 'visible' : 'hidden')};
pointer-events: ${({$inView}) => ($inView ? 'inherit' : 'none')};
`

export function CollapseMenu(props: CollapseMenuProps) {
const {children, menuButton, gap = 1, onMenuVisible, menuScheme} = props
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
const [expandedRef, setExpandedRef] = useState<HTMLDivElement | null>(null)
const [collapsedInnerRef, setCollapsedInnerRef] = useState<HTMLDivElement | null>(null)
const [collapsed, setCollapsed] = useState<boolean>(true)
const [menuOptions, setMenuOptions] = useState<ReactElement[] | []>([])
const rootRect = useElementRect(rootRef)
const childrenArray = useMemo(() => Children.toArray(children) as ReactElement[], [children])

//Filter to get the latest state of menu options
const menuOptionsArray = useMemo(
() => childrenArray.filter(({key}) => menuOptions.find((o: ReactElement) => o.key === key)),
[childrenArray, menuOptions]
)

// Pick what button to render as menu button
const menuButtonToRender = useMemo(() => {
if (menuButton) {
return menuButton
}
return <Button mode="bleed" icon={EllipsisVerticalIcon} />
}, [menuButton])

const menuIsVisible = collapsed && menuOptionsArray.length > 0

useEffect(() => {
if (onMenuVisible) {
onMenuVisible(menuIsVisible)
}
}, [menuIsVisible, onMenuVisible])

// Add or remove option in menuOptions
const handleInViewChange = useCallback(
(payload: {child: ReactElement; inView: boolean}) => {
const {child, inView} = payload
const exists = menuOptions.some((o: ReactElement) => o.key === child.key)

if (!inView && !exists) {
setMenuOptions((prev) => [child, ...prev])
}

if (inView && exists) {
const updatedOptions = menuOptions.filter(({key}) => key !== child.key)
setMenuOptions(updatedOptions)
}
},
[menuOptions]
)

//Check if child is in menu
const isInMenu = useCallback(
(option) => {
const exists = menuOptions.some(({key}) => key === option.key)
return exists
},
[menuOptions]
)

//Check if menu should collapse
useEffect(() => {
if (rootRect && expandedRef) {
const collapse = rootRect.width < expandedRef.scrollWidth
setCollapsed(collapse)
}
}, [expandedRef, rootRect])

return (
<Root ref={setRootRef} display="flex" data-ui="CollapseMenu" sizing="border">
{/* Expanded row */}
<Inner ref={setExpandedRef} $hide={collapsed} aria-hidden={collapsed}>
<Flex as="ul" gap={gap}>
{childrenArray.map((child) => {
return (
<Box as="li" key={child.key}>
{cloneElement(child, {...child.props}, null)}
</Box>
)
})}
</Flex>
</Inner>

{/* Collapsed row */}
<Inner gap={gap} $hide={!collapsed} aria-hidden={!collapsed}>
<Flex ref={setCollapsedInnerRef} gap={gap} as="ul">
{childrenArray.map((child) => {
if (child.type === CollapseMenuDivider) {
return child
}

if (child.type !== CollapseMenuButton) {
return child
}

return (
<InView
// eslint-disable-next-line react/jsx-no-bind
onChange={(inView) => handleInViewChange({inView, child: child})}
root={collapsedInnerRef}
key={child.key}
threshold={1}
rootMargin="0px 2px 0px 0px"
aria-hidden={isInMenu(child.key)}
>
{({ref, inView}) => (
<OptionBox ref={ref} as="li" $inView={inView && collapsed}>
<Tooltip
portal
scheme={child.props.tooltipScheme}
disabled={!inView}
content={
<Box padding={2} sizing="border">
<Text size={1} muted>
{child.props.text}
</Text>
</Box>
}
>
<Box>
{cloneElement(
child,
{
...child.props,
text: null,
'aria-label': child.props.text,
disabled: !inView,
},
null
)}
</Box>
</Tooltip>
</OptionBox>
)}
</InView>
)
})}
</Flex>
</Inner>

{/* Menu */}
{menuIsVisible && (
<MenuButton
button={menuButtonToRender}
id="collapse-menu"
popoverScheme={menuScheme}
menu={
<Menu>
{menuOptionsArray.map((child) => {
return (
<MenuItem
{...child.props}
key={child.key}
fontSize={2}
radius={2}
selected={false}
pressed={child.props.selected}
/>
)
})}
</Menu>
}
placement="bottom"
popover={{portal: true, preventOverflow: true}}
/>
)}
</Root>
)
}
@@ -0,0 +1,13 @@
import React from 'react'
import {Button, ButtonProps, ThemeColorSchemeKey} from '@sanity/ui'

export interface CollapseMenuButtonProps
extends Omit<ButtonProps, 'text' | 'icon' | 'children' | 'iconRight'> {
text: React.ReactNode
icon: React.ComponentType | React.ReactNode
tooltipScheme?: ThemeColorSchemeKey
}

export function CollapseMenuButton({...props}: CollapseMenuButtonProps) {
return <Button {...props} data-ui="CollapseMenuButton" />
}
@@ -0,0 +1,6 @@
import React from 'react'
import {Card} from '@sanity/ui'

export function CollapseMenuDivider() {
return <Card borderRight data-ui="CollapseMenuDivider" tone="inherit" height="fill" />
}
@@ -0,0 +1,3 @@
export * from './CollapseMenu'
export * from './CollapseMenuButton'
export * from './CollapseMenuDivider'
@@ -0,0 +1,2 @@
export * from './collapseMenu'
export * from './StatusButton'
@@ -0,0 +1,60 @@
import {PlugIcon} from '@sanity/icons'
import {StateLink} from '@sanity/state-router/components'
import React, {forwardRef, useMemo} from 'react'
import {Tool, Router} from '../../types'
import {CollapseMenu, CollapseMenuButton} from '../components'

export function ToolMenuCollapse({tools, router}: {tools: Tool[]; router: Router}) {
const tool = router.state?.tool || ''

const LinkComponent = useMemo(
() =>
// eslint-disable-next-line no-shadow
forwardRef(function LinkComponent(
props: {children: React.ReactNode; tool: Tool},
ref: React.ForwardedRef<HTMLAnchorElement>
) {
const {name} = props.tool as Tool
const linkProps = {...props, router: null, tool: null, title: null}

return (
<StateLink
{...linkProps}
state={{...router.state, tool: name, [name]: undefined}}
ref={ref}
/>
)
}),
[router.state]
)

const toolsOptions = useMemo(
() =>
tools.map((t) => {
return {
...t,
icon: t?.icon || PlugIcon,
text: t.title || t.name,
tool: t,
selected: tool === t.name,
}
}),
[tool, tools]
)

return (
<CollapseMenu menuScheme="light">
{toolsOptions.map((t) => (
<CollapseMenuButton
key={t?.name}
as={LinkComponent}
data-as="a"
mode="bleed"
tooltipScheme="light"
tool={t}
{...t}
/>
))}
</CollapseMenu>
)
}
@@ -0,0 +1 @@
export * from './ToolMenuCollapse'

0 comments on commit d8cd710

Please sign in to comment.