diff --git a/.changeset/curvy-stingrays-allow.md b/.changeset/curvy-stingrays-allow.md
new file mode 100644
index 00000000000..57f3fee5e5b
--- /dev/null
+++ b/.changeset/curvy-stingrays-allow.md
@@ -0,0 +1,13 @@
+---
+'braid-design-system': patch
+---
+
+---
+updated:
+ - Tabs
+---
+
+**Tabs:** Provide left and right scroll affordance
+
+Previously a fade out was applied to the right to provide a visual cue that there were more tabs overflowing to the right.
+This technique is now applied to the left as well, providing cues in both directions.
diff --git a/.changeset/fast-hounds-kick.md b/.changeset/fast-hounds-kick.md
new file mode 100644
index 00000000000..6d3ab6ef38f
--- /dev/null
+++ b/.changeset/fast-hounds-kick.md
@@ -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.
diff --git a/packages/braid-design-system/package.json b/packages/braid-design-system/package.json
index 13a0522ac32..d8f8c8ca2a7 100644
--- a/packages/braid-design-system/package.json
+++ b/packages/braid-design-system/package.json
@@ -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"
},
@@ -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",
diff --git a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.css.ts b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.css.ts
index 8bbbdd32072..0ce39fb86ff 100644
--- a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.css.ts
+++ b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.css.ts
@@ -34,5 +34,4 @@ export const menuHeightLimit = style({
.multiply(9.5)
.add(vars.space[menuYPadding])
.toString(),
- overflowY: 'auto',
});
diff --git a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.tsx b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.tsx
index ce3874e9d5c..50a76b4231f 100644
--- a/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.tsx
+++ b/packages/braid-design-system/src/lib/components/MenuRenderer/MenuRenderer.tsx
@@ -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';
@@ -368,6 +369,7 @@ export function Menu({
boxShadow={placement === 'top' ? 'small' : 'medium'}
borderRadius={borderRadius}
background="surface"
+ paddingY={styles.menuYPadding}
marginTop={placement === 'bottom' ? offsetSpace : undefined}
marginBottom={placement === 'top' ? offsetSpace : undefined}
transition="fast"
@@ -380,30 +382,32 @@ export function Menu({
placement === 'top' && styles.placementBottom,
]}
>
-
- {Children.map(children, (item, i) => {
- if (isDivider(item)) {
- dividerCount++;
- return item;
- }
+
+
+ {Children.map(children, (item, i) => {
+ if (isDivider(item)) {
+ dividerCount++;
+ return item;
+ }
- const menuItemIndex = i - dividerCount;
+ const menuItemIndex = i - dividerCount;
- return (
-
- {item}
-
- );
- })}
-
+ return (
+
+ {item}
+
+ );
+ })}
+
+
,
),
},
+ {
+ label: 'Reserve hit area',
+ description: (
+ <>
+
+ By default, a Tab will only occupy the vertical
+ space from the top of the label to the active underline. This means
+ the hit area will bleed out into the space above.
+
+
+ The bleed can be disabled by setting the{' '}
+ reserveHitArea prop to true.
+
+ >
+ ),
+ Example: ({ id }) =>
+ source(
+
+
+
+ The first tab
+ The second tab
+ The third tab
+ New}>
+ The fourth tab
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ ),
+ },
{
label: 'State management',
description: (
@@ -398,7 +443,7 @@ const docs: ComponentDocs = {
onChange={(index, item) => setState('tab', item)}
>
-
+
The first tab
The second tab
The third tab
diff --git a/packages/braid-design-system/src/lib/components/Tabs/Tabs.tsx b/packages/braid-design-system/src/lib/components/Tabs/Tabs.tsx
index 3a601ba693f..44c980f3c29 100644
--- a/packages/braid-design-system/src/lib/components/Tabs/Tabs.tsx
+++ b/packages/braid-design-system/src/lib/components/Tabs/Tabs.tsx
@@ -3,7 +3,6 @@ import React, {
useEffect,
useRef,
useState,
- useCallback,
type ReactElement,
type ComponentProps,
} from 'react';
@@ -20,7 +19,7 @@ import buildDataAttributes, {
import { TabsContext } from './TabsProvider';
import { negativeMargin } from '../../css/negativeMargin/negativeMargin';
import type { ReactNodeNoStrings } from '../private/ReactNodeNoStrings';
-import { useBraidTheme } from '../BraidProvider/BraidThemeContext';
+import { ScrollContainer } from '../private/ScrollContainer/ScrollContainer';
import { TabListContext, type TabSize } from './TabListContext';
import * as styles from './Tabs.css';
import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect';
@@ -44,6 +43,18 @@ interface TabLinePosition {
const tabLinePositionDefault: TabLinePosition = { left: 0, width: 0 };
+const TabsDivider = () => (
+
+
+
+);
+
// This must be called within a `useLayoutEffect` because `.getComputedStyle()` and `.getBoundingClientRect()` force a reflow
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a
const getActiveTabLinePosition = (
@@ -125,33 +136,6 @@ export const Tabs = (props: TabsProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabItems.join(), dispatch]);
- const {
- space: { grid, space },
- } = useBraidTheme();
- const [showMask, setShowMask] = useState(true);
-
- // This must be called within a `useLayoutEffect` because `.scrollLeft`, `.scrollWidth` and `.offsetWidth` force a reflow
- // https://gist.github.com/paulirish/5d52fb081b3570c81e3a
- const updateMask = useCallback(() => {
- if (!tabsRef.current) {
- return;
- }
-
- setShowMask(
- tabsRef.current.scrollWidth -
- tabsRef.current.offsetWidth -
- tabsRef.current.scrollLeft >
- grid * space.small,
- );
- }, [tabsRef, setShowMask, grid, space]);
-
- useIsomorphicLayoutEffect(() => {
- updateMask();
-
- window.addEventListener('resize', updateMask);
- return () => window.removeEventListener('resize', updateMask);
- }, [updateMask]);
-
const selectedTabIndex =
typeof selectedItem !== 'undefined'
? tabItems.indexOf(selectedItem)
@@ -166,87 +150,55 @@ export const Tabs = (props: TabsProps) => {
return (
-
+ {divider === 'full' ? : null}
+
-
-
- {tabs}
- {divider === 'minimal' ? (
-
-
-
- ) : null}
- {selectedTabButtonEl ? (
-
- ) : null}
-
-
- {divider === 'full' ? (
+ {tabs}
+ {divider === 'minimal' ? : null}
+ {selectedTabButtonEl ? (
-
-
+ bottom={0}
+ background="formAccent"
+ pointerEvents="none"
+ className={[
+ styles.tabUnderline,
+ styles.tabUnderlineActiveDarkMode,
+ ]}
+ style={assignInlineVars({
+ [styles.underlineLeft]: activeTabPosition.left.toString(),
+ [styles.underlineWidth]: activeTabPosition.width.toString(),
+ })}
+ />
) : null}
-
+
);
diff --git a/packages/braid-design-system/src/lib/components/private/ScrollContainer/ScrollContainer.css.ts b/packages/braid-design-system/src/lib/components/private/ScrollContainer/ScrollContainer.css.ts
new file mode 100644
index 00000000000..a0509446f6c
--- /dev/null
+++ b/packages/braid-design-system/src/lib/components/private/ScrollContainer/ScrollContainer.css.ts
@@ -0,0 +1,87 @@
+import {
+ createVar,
+ fallbackVar,
+ style,
+ styleVariants,
+} from '@vanilla-extract/css';
+
+export const container = style({
+ WebkitOverflowScrolling: 'touch',
+ scrollbarWidth: 'none',
+ msOverflowStyle: 'none',
+ maskComposite: 'intersect',
+ '::-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,
+ },
+});
diff --git a/packages/braid-design-system/src/lib/components/private/ScrollContainer/ScrollContainer.tsx b/packages/braid-design-system/src/lib/components/private/ScrollContainer/ScrollContainer.tsx
new file mode 100644
index 00000000000..d91cce3a229
--- /dev/null
+++ b/packages/braid-design-system/src/lib/components/private/ScrollContainer/ScrollContainer.tsx
@@ -0,0 +1,85 @@
+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;
+ data?: DataAttributeMap;
+}
+
+export const ScrollContainer = ({
+ children,
+ direction = 'horizontal',
+ fadeSize = 'medium',
+ data,
+ ...restProps
+}: ScrollContainerProps) => {
+ const containerRef = useRef(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 (
+
+ {children}
+
+ );
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e884a87e035..3a325ff20cc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -227,6 +227,9 @@ importers:
react-remove-scroll:
specifier: ^2.5.3
version: 2.5.3(@types/react@18.3.3)(react@18.3.1)
+ throttle-debounce:
+ specifier: ^5.0.2
+ version: 5.0.2
utility-types:
specifier: ^3.10.0
version: 3.10.0
@@ -306,6 +309,9 @@ importers:
'@types/testing-library__jest-dom':
specifier: ^5.9.1
version: 5.14.3
+ '@types/throttle-debounce':
+ specifier: ^5.0.2
+ version: 5.0.2
'@types/uuid':
specifier: ^8.3.0
version: 8.3.4
@@ -2971,6 +2977,9 @@ packages:
'@types/testing-library__jest-dom@5.14.3':
resolution: {integrity: sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==}
+ '@types/throttle-debounce@5.0.2':
+ resolution: {integrity: sha512-pDzSNulqooSKvSNcksnV72nk8p7gRqN8As71Sp28nov1IgmPKWbOEIwAWvBME5pPTtaXJAvG3O4oc76HlQ4kqQ==}
+
'@types/tough-cookie@4.0.2':
resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==}
@@ -8481,6 +8490,10 @@ packages:
resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==}
engines: {node: '>=10'}
+ throttle-debounce@5.0.2:
+ resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
+ engines: {node: '>=12.22'}
+
through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
@@ -9297,7 +9310,7 @@ snapshots:
dependencies:
'@babel/core': 7.24.5
'@babel/helper-compilation-targets': 7.24.7
- '@babel/helper-plugin-utils': 7.24.8
+ '@babel/helper-plugin-utils': 7.25.9
debug: 4.3.6(supports-color@8.1.1)
lodash.debounce: 4.0.8
resolve: 1.22.8
@@ -9309,7 +9322,7 @@ snapshots:
dependencies:
'@babel/core': 7.24.5
'@babel/helper-compilation-targets': 7.24.7
- '@babel/helper-plugin-utils': 7.24.8
+ '@babel/helper-plugin-utils': 7.25.9
debug: 4.3.6(supports-color@8.1.1)
lodash.debounce: 4.0.8
resolve: 1.22.8
@@ -9499,7 +9512,7 @@ snapshots:
'@babel/plugin-syntax-flow@7.24.7(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.5
- '@babel/helper-plugin-utils': 7.24.8
+ '@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-import-assertions@7.24.7(@babel/core@7.24.5)':
dependencies:
@@ -9687,7 +9700,7 @@ snapshots:
'@babel/plugin-transform-flow-strip-types@7.24.7(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.5
- '@babel/helper-plugin-utils': 7.24.8
+ '@babel/helper-plugin-utils': 7.25.9
'@babel/plugin-syntax-flow': 7.24.7(@babel/core@7.24.5)
'@babel/plugin-transform-for-of@7.24.7(@babel/core@7.24.5)':
@@ -10058,7 +10071,7 @@ snapshots:
'@babel/preset-flow@7.24.7(@babel/core@7.24.5)':
dependencies:
'@babel/core': 7.24.5
- '@babel/helper-plugin-utils': 7.24.8
+ '@babel/helper-plugin-utils': 7.25.9
'@babel/helper-validator-option': 7.24.7
'@babel/plugin-transform-flow-strip-types': 7.24.7(@babel/core@7.24.5)
@@ -12320,6 +12333,8 @@ snapshots:
dependencies:
'@types/jest': 29.1.2
+ '@types/throttle-debounce@5.0.2': {}
+
'@types/tough-cookie@4.0.2': {}
'@types/unist@2.0.6': {}
@@ -19471,6 +19486,8 @@ snapshots:
throttle-debounce@3.0.1: {}
+ throttle-debounce@5.0.2: {}
+
through2@2.0.5:
dependencies:
readable-stream: 2.3.7