Skip to content

Commit

Permalink
MenuRenderer, OverflowMenu: Provide improved scroll affordance (#1661)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaeltaranto authored Dec 4, 2024
1 parent 6a118bc commit ca2e854
Show file tree
Hide file tree
Showing 7 changed files with 242 additions and 34 deletions.
13 changes: 13 additions & 0 deletions .changeset/fast-hounds-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'braid-design-system': patch
---

---
updated:
- MenuRenderer
- OverflowMenu
---

**MenuRenderer, OverflowMenu:** Provide improved scroll affordance

Introduce scroll affordance to menus, providing a visual cue that there are more items overflowing vertically.
2 changes: 2 additions & 0 deletions packages/braid-design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
"react-is": "^18.2.0",
"react-popper-tooltip": "^4.3.1",
"react-remove-scroll": "^2.5.3",
"throttle-debounce": "^5.0.2",
"utility-types": "^3.10.0",
"uuid": "^8.3.2"
},
Expand Down Expand Up @@ -234,6 +235,7 @@
"@types/react-router-dom": "^5.3.3",
"@types/sanitize-html": "^2.6.2",
"@types/testing-library__jest-dom": "^5.9.1",
"@types/throttle-debounce": "^5.0.2",
"@types/uuid": "^8.3.0",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-transform-remove-imports": "^1.7.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ export const placementBottom = style({
bottom: '100%',
});

export const menuYPadding = 'xxsmall';

export const menuHeightLimit = style({
maxHeight: calc(vars.touchableSize)
.multiply(9.5)
.add(vars.space[menuYPadding])
.toString(),
overflowY: 'auto',
maxHeight: calc(vars.touchableSize).multiply(9.5).toString(),
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { MenuItemDivider } from '../MenuItemDivider/MenuItemDivider';
import { normalizeKey } from '../private/normalizeKey';
import { getNextIndex } from '../private/getNextIndex';
import { Overlay } from '../private/Overlay/Overlay';
import { ScrollContainer } from '../private/ScrollContainer/ScrollContainer';
import { type Action, actionTypes } from './MenuRenderer.actions';
import { MenuRendererContext } from './MenuRendererContext';
import { MenuRendererItemContext } from './MenuRendererItemContext';
Expand Down Expand Up @@ -368,6 +369,7 @@ export function Menu({
boxShadow={placement === 'top' ? 'small' : 'medium'}
borderRadius={borderRadius}
background="surface"
paddingY="xxsmall"
marginTop={placement === 'bottom' ? offsetSpace : undefined}
marginBottom={placement === 'top' ? offsetSpace : undefined}
transition="fast"
Expand All @@ -380,30 +382,32 @@ export function Menu({
placement === 'top' && styles.placementBottom,
]}
>
<Box paddingY={styles.menuYPadding} className={styles.menuHeightLimit}>
{Children.map(children, (item, i) => {
if (isDivider(item)) {
dividerCount++;
return item;
}
<ScrollContainer direction="vertical" fadeSize="small">
<Box className={styles.menuHeightLimit}>
{Children.map(children, (item, i) => {
if (isDivider(item)) {
dividerCount++;
return item;
}

const menuItemIndex = i - dividerCount;
const menuItemIndex = i - dividerCount;

return (
<MenuRendererItemContext.Provider
key={menuItemIndex}
value={{
isHighlighted: menuItemIndex === highlightIndex,
index: menuItemIndex,
dispatch,
focusTrigger,
}}
>
{item}
</MenuRendererItemContext.Provider>
);
})}
</Box>
return (
<MenuRendererItemContext.Provider
key={menuItemIndex}
value={{
isHighlighted: menuItemIndex === highlightIndex,
index: menuItemIndex,
dispatch,
focusTrigger,
}}
>
{item}
</MenuRendererItemContext.Provider>
);
})}
</Box>
</ScrollContainer>
<Overlay
boxShadow="borderNeutralLight"
borderRadius={borderRadius}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
createVar,
fallbackVar,
style,
styleVariants,
} from '@vanilla-extract/css';

export const container = style({
WebkitOverflowScrolling: 'touch',
maskComposite: 'intersect',
});

export const hideScrollbar = style({
scrollbarWidth: 'none',
msOverflowStyle: 'none',
'::-webkit-scrollbar': {
width: 0,
height: 0,
},
});

const scrollOverlaySize = createVar();
export const fadeSize = styleVariants({
small: {
vars: {
[scrollOverlaySize]: '40px',
},
},
medium: {
vars: {
[scrollOverlaySize]: '60px',
},
},
large: {
vars: {
[scrollOverlaySize]: '80px',
},
},
});

export const direction = styleVariants({
horizontal: {
overflowX: 'auto',
overflowY: 'hidden',
},
vertical: {
overflowX: 'hidden',
overflowY: 'auto',
},
all: {
overflow: 'auto',
},
});

const left = createVar();
const right = createVar();
const top = createVar();
const bottom = createVar();
export const mask = style({
maskImage: [
`linear-gradient(to bottom, transparent 0, black ${fallbackVar(top, '0')})`,
`linear-gradient(to right, transparent 0, black ${fallbackVar(left, '0')})`,
`linear-gradient(to left, transparent 0, black ${fallbackVar(right, '0')})`,
`linear-gradient(to top, transparent 0, black ${fallbackVar(bottom, '0')})`,
].join(','),
});

export const maskLeft = style({
vars: {
[left]: scrollOverlaySize,
},
});

export const maskRight = style({
vars: {
[right]: scrollOverlaySize,
},
});

export const maskTop = style({
vars: {
[top]: scrollOverlaySize,
},
});

export const maskBottom = style({
vars: {
[bottom]: scrollOverlaySize,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { type ReactNode, useRef, useCallback } from 'react';
import { useIsomorphicLayoutEffect } from '../../../hooks/useIsomorphicLayoutEffect';
import { Box } from '../../Box/Box';
import buildDataAttributes, {
type DataAttributeMap,
} from '../buildDataAttributes';
import { throttle } from 'throttle-debounce';

import * as styles from './ScrollContainer.css';

const maskOverflow = (
element: HTMLElement,
direction: keyof typeof styles.direction,
) => {
const atTop = element.scrollTop === 0;
const atBottom =
element.scrollHeight - element.offsetHeight - element.scrollTop < 1;
const atLeft = element.scrollLeft === 0;
const atRight =
element.scrollWidth - element.offsetWidth - element.scrollLeft < 1;

if (direction === 'vertical' || direction === 'all') {
element.classList[atTop ? 'remove' : 'add'](styles.maskTop);
element.classList[atBottom ? 'remove' : 'add'](styles.maskBottom);
}
if (direction === 'horizontal' || direction === 'all') {
element.classList[atLeft ? 'remove' : 'add'](styles.maskLeft);
element.classList[atRight ? 'remove' : 'add'](styles.maskRight);
}
};

interface ScrollContainerProps {
children: ReactNode;
direction?: keyof typeof styles.direction;
fadeSize?: keyof typeof styles.fadeSize;
hideScrollbar?: boolean;
data?: DataAttributeMap;
}

export const ScrollContainer = ({
children,
direction = 'horizontal',
fadeSize = 'medium',
hideScrollbar = false,
data,
...restProps
}: ScrollContainerProps) => {
const containerRef = useRef<HTMLDivElement>(null);

// This must be called within a `useLayoutEffect` because `.scrollLeft`, `.scrollWidth` and `.offsetWidth` force a reflow
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a
const updateMask = throttle(
100,
useCallback(() => {
if (containerRef.current) {
maskOverflow(containerRef.current, direction);
}
}, [containerRef, direction]),
);

useIsomorphicLayoutEffect(() => {
if (containerRef.current) {
updateMask();
}

window.addEventListener('resize', updateMask);
return () => window.removeEventListener('resize', updateMask);
}, [updateMask]);

return (
<Box
ref={containerRef}
onScroll={updateMask}
position="relative"
height="full"
className={[
styles.container,
styles.mask,
hideScrollbar ? styles.hideScrollbar : null,
styles.fadeSize[fadeSize],
styles.direction[direction],
]}
{...buildDataAttributes({ data, validateRestProps: restProps })}
>
{children}
</Box>
);
};
27 changes: 22 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ca2e854

Please sign in to comment.