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