@beydesign/storybook 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ import { Typography, TypographySize, TypographyWeight } from '../Typography';
5
5
  import { HintBubble, HintBubbleSize } from '../UI';
6
6
  export const TextArea = ({ label, value, onChange, placeholder, hintText, errorText, disabled, allowMaxLength = false, maxLength = 256, showMaxLengthIndicator = false, numberOfRows = 5, style, hintBubbleProps, hintBubbleText, required, labelTrailingElement, labelContainerStyle, }) => {
7
7
  const [focused, setFocused] = useState(false);
8
+ const showMaxLength = Boolean(showMaxLengthIndicator && allowMaxLength && maxLength);
9
+ const showFooter = Boolean(errorText || hintText) || showMaxLength;
8
10
  return (_jsx(Box, { display: 'flex', flexDirection: 'column', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', sx: style, children: _jsxs(Box, { display: 'flex', flexDirection: 'column', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-lg)', minWidth: '260px', children: [label && (_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', sx: labelContainerStyle, children: [_jsx(Typography, { size: TypographySize.HeadingXS, weight: TypographyWeight.SemiBold, required: required, children: label }), labelTrailingElement] })), hintBubbleText && (_jsx(HintBubble, { text: hintBubbleText, size: HintBubbleSize.sm, ...hintBubbleProps })), _jsx(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', bgcolor: 'var(--color-background-bg-component)', border: `1px solid ${errorText
9
11
  ? 'var(--color-border-border-error)'
10
12
  : focused
@@ -19,5 +21,5 @@ export const TextArea = ({ label, value, onChange, placeholder, hintText, errorT
19
21
  : 'var(--color-text-text-title)',
20
22
  backgroundColor: 'transparent',
21
23
  resize: 'none',
22
- }, maxLength: allowMaxLength ? maxLength : undefined, onFocus: () => setFocused(true), onBlur: () => setFocused(false), required: required }) }), _jsxs(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', children: [errorText ? (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-error)', children: errorText })) : hintText ? (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-subtle)', children: hintText })) : (_jsx(Box, { style: { flex: 1 } })), showMaxLengthIndicator && allowMaxLength && maxLength && (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-subtle)', children: `${maxLength - value.length}` }))] })] }) }));
24
+ }, maxLength: allowMaxLength ? maxLength : undefined, onFocus: () => setFocused(true), onBlur: () => setFocused(false), required: required }) }), showFooter && (_jsxs(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', children: [errorText ? (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-error)', children: errorText })) : hintText ? (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-subtle)', children: hintText })) : (_jsx(Box, { style: { flex: 1 } })), showMaxLength && (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: 'var(--color-text-text-subtle)', children: `${maxLength - value.length}` }))] }))] }) }));
23
25
  };
@@ -26,7 +26,9 @@ export const TextInput = ({ label, value, onChange, placeholder, type = 'text',
26
26
  : 'var(--color-foreground-fg-secondary)';
27
27
  return (_jsx(Box, { display: 'flex', flexDirection: 'column', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', width: fullWidth ? '100%' : undefined, minWidth: fullWidth ? 0 : undefined, sx: style, children: _jsxs(Box, { display: 'flex', flexDirection: 'column', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-lg)', width: fullWidth ? '100%' : undefined, minWidth: fullWidth ? 0 : '260px', children: [label && (_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', sx: labelContainerStyle, children: [_jsx(Typography, { size: TypographySize.HeadingXS, weight: TypographyWeight.SemiBold, required: required, children: label }), labelTrailingElement] })), hintBubbleText && (_jsx(HintBubble, { text: hintBubbleText, size: HintBubbleSize.sm, ...hintBubbleProps })), _jsxs(Box, { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', height: '40px', bgcolor: 'var(--color-background-bg-component)', border: errorText
28
28
  ? 'none'
29
- : '1px solid var(--color-border-border-component)', overflow: 'hidden', borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-xs)', sx: { boxSizing: 'border-box' }, children: [_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', padding: '0 var(--spacing-tokens-size-in-px-spacing-spacing-lg)', height: '100%', width: '100%', sx: { boxSizing: 'border-box' }, borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-xs) 0 0 var(--spacing-tokens-size-in-px-spacing-spacing-xs)', border: `${errorText
29
+ : '1px solid var(--color-border-border-component)', overflow: 'hidden', borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-xs)', sx: { boxSizing: 'border-box' }, children: [_jsxs(Box, { display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)', padding: '0 var(--spacing-tokens-size-in-px-spacing-spacing-lg)', height: '100%', width: '100%', sx: { boxSizing: 'border-box' }, borderRadius: trailingText
30
+ ? 'var(--spacing-tokens-size-in-px-spacing-spacing-xs) 0 0 var(--spacing-tokens-size-in-px-spacing-spacing-xs)'
31
+ : 'var(--spacing-tokens-size-in-px-spacing-spacing-xs)', border: `${errorText
30
32
  ? '1px solid var(--color-border-border-error)'
31
33
  : focused
32
34
  ? '1px solid var(--color-border-border-component-active)'
@@ -11,7 +11,10 @@ import { getIconComponent } from '../../utils';
11
11
  export const ButtonCard = ({ title, description, icon = 'UserFocus', iconColor = 'var(--color-foreground-fg-accent-yellow)', iconBackgroundColor = 'var(--color-background-bg-badge-accent-yellow)', onClick, showActionButton = false, actionButtonText = 'Contact Us', onActionClick, disabled = false, width = '294px', style, testId, }) => {
12
12
  const cardKey = title?.toLowerCase()?.replace(/\s+/g, '-');
13
13
  const isInteractive = !disabled && !!onClick;
14
- return (_jsxs(Box, { width: width, display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', paddingX: 'var(--spacing-tokens-size-in-px-spacing-spacing-xl)', paddingY: 'var(--spacing-tokens-size-in-px-spacing-spacing-4xl)', borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', bgcolor: 'var(--color-background-bg-component)', boxSizing: 'border-box', position: 'relative', onClick: isInteractive ? onClick : undefined, style: style, "data-testid": testId ?? `${cardKey}-button-card`, sx: {
14
+ return (_jsxs(Box, { width: width, display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', paddingX: 'var(--spacing-tokens-size-in-px-spacing-spacing-xl)', paddingY: {
15
+ xs: 'var(--spacing-tokens-size-in-px-spacing-spacing-xl)',
16
+ sm: 'var(--spacing-tokens-size-in-px-spacing-spacing-4xl)',
17
+ }, borderRadius: 'var(--spacing-tokens-size-in-px-spacing-spacing-md)', bgcolor: 'var(--color-background-bg-component)', boxSizing: 'border-box', position: 'relative', onClick: isInteractive ? onClick : undefined, style: style, "data-testid": testId ?? `${cardKey}-button-card`, sx: {
15
18
  opacity: disabled ? 0.5 : 1,
16
19
  cursor: isInteractive ? 'pointer' : 'default',
17
20
  pointerEvents: disabled ? 'none' : 'auto',
@@ -25,7 +25,7 @@ const IMAGE_COLUMN = [
25
25
  BANNER_AVATARS.yuruo,
26
26
  ];
27
27
  export const ConversationalAvatarsBanner = ({ title, description, actions = [], background = DEFAULT_BACKGROUND, style: customStyle, testId, }) => {
28
- const renderAction = (action, index) => (_jsx(MainButton, { text: action.text, size: ButtonSize.md, hierarchy: action.primary
28
+ const renderAction = (action, index) => (_jsx(MainButton, { text: action.text, size: ButtonSize.xxs, hierarchy: action.primary
29
29
  ? ButtonHierarchy.Primary
30
30
  : ButtonHierarchy.SecondaryColor, onClick: action.onClick, testId: testId ? `${testId}-action-${index}` : undefined }, `${action.text}-${index}`));
31
31
  return (_jsxs(Box, { display: "flex", alignItems: "center", gap: "40px", width: "100%", minHeight: `${BANNER_MIN_HEIGHT}px`, overflow: "hidden", borderRadius: "var(--spacing-tokens-size-in-px-radius-radius-lg)", padding: "var(--spacing-tokens-size-in-px-spacing-spacing-3xl) var(--spacing-tokens-size-in-px-spacing-spacing-2xl)", sx: {
@@ -11,7 +11,7 @@ const MOBILE = '@media (max-width: 640px)';
11
11
  const PREVIEW_WIDTH = 296;
12
12
  const PREVIEW_HEIGHT = 150;
13
13
  export const ConversationalVoiceAgentsBanner = ({ title, description, actions = [], previewImage = BANNER_VIDEO_PREVIEW, onPlayClick, previewCtaText, onPreviewCtaClick, background = PINK_GRADIENT, style: customStyle, testId, }) => {
14
- const renderAction = (action, index) => (_jsx(MainButton, { text: action.text, size: ButtonSize.md, hierarchy: action.primary
14
+ const renderAction = (action, index) => (_jsx(MainButton, { text: action.text, size: ButtonSize.xxs, hierarchy: action.primary
15
15
  ? ButtonHierarchy.Primary
16
16
  : ButtonHierarchy.SecondaryColor, onClick: action.onClick, testId: testId ? `${testId}-action-${index}` : undefined }, `${action.text}-${index}`));
17
17
  return (_jsxs(Box, { display: "flex", alignItems: "center", gap: "40px", width: "100%", overflow: "hidden", borderRadius: "var(--spacing-tokens-size-in-px-radius-radius-lg)", padding: "var(--spacing-tokens-size-in-px-spacing-spacing-3xl) var(--spacing-tokens-size-in-px-spacing-spacing-2xl)", sx: {
@@ -37,5 +37,5 @@ export const ConversationalVoiceAgentsBanner = ({ title, description, actions =
37
37
  height: 'auto',
38
38
  aspectRatio: `${PREVIEW_WIDTH} / ${PREVIEW_HEIGHT}`,
39
39
  },
40
- }, children: [_jsx(Box, { component: "img", src: previewImage, alt: "", width: "100%", height: "100%", sx: { objectFit: 'cover', display: 'block' } }), _jsx(Box, { position: "absolute", left: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", bottom: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", children: _jsx(MainButton, { size: ButtonSize.md, hierarchy: ButtonHierarchy.SecondaryTransparent, displayIconMode: ButtonDisplayMode.Only, displayIcon: "Play", onClick: onPlayClick, style: { border: 'none' }, testId: testId ? `${testId}-play` : undefined }) }), previewCtaText && (_jsx(Box, { position: "absolute", right: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", bottom: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", children: _jsx(MainButton, { text: previewCtaText, size: ButtonSize.md, hierarchy: ButtonHierarchy.SecondaryTransparent, onClick: onPreviewCtaClick, style: { border: 'none' }, testId: testId ? `${testId}-preview-cta` : undefined }) }))] })] }));
40
+ }, children: [_jsx(Box, { component: "img", src: previewImage, alt: "", width: "100%", height: "100%", sx: { objectFit: 'cover', display: 'block' } }), _jsx(Box, { position: "absolute", left: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", bottom: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", children: _jsx(MainButton, { size: ButtonSize.xxs, hierarchy: ButtonHierarchy.SecondaryTransparent, displayIconMode: ButtonDisplayMode.Only, displayIcon: "Play", onClick: onPlayClick, style: { border: 'none' }, testId: testId ? `${testId}-play` : undefined }) }), previewCtaText && (_jsx(Box, { position: "absolute", right: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", bottom: "var(--spacing-tokens-size-in-px-spacing-spacing-md)", children: _jsx(MainButton, { text: previewCtaText, size: ButtonSize.xxs, hierarchy: ButtonHierarchy.SecondaryTransparent, onClick: onPreviewCtaClick, style: { border: 'none' }, testId: testId ? `${testId}-preview-cta` : undefined }) }))] })] }));
41
41
  };
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import React, { useState } from 'react';
2
+ import React, { useRef, useState } from 'react';
3
3
  import { Tooltip, ClickAwayListener } from '@mui/material';
4
4
  import { PopoverVariant } from './types';
5
5
  /** Composed from the semantic `shadow-sm` design token. */
@@ -21,6 +21,7 @@ const VARIANT_COLOR = {
21
21
  */
22
22
  export const Popover = ({ content, children, variant = PopoverVariant.Dark, placement = 'top', trigger = 'hover', arrow = true, maxWidth = 280, padding = 'var(--spacing-tokens-size-in-px-spacing-spacing-lg)', open, onOpenChange, disableInteractive = false, className, }) => {
23
23
  const [internalOpen, setInternalOpen] = useState(false);
24
+ const popperRef = useRef(null);
24
25
  const isControlled = open !== undefined;
25
26
  const isOpen = isControlled ? open : internalOpen;
26
27
  const setOpen = (next) => {
@@ -53,7 +54,17 @@ export const Popover = ({ content, children, variant = PopoverVariant.Dark, plac
53
54
  setOpen(!isOpen);
54
55
  },
55
56
  });
56
- return (_jsx(ClickAwayListener, { onClickAway: () => setOpen(false), children: _jsx(Tooltip, { open: isOpen, placement: placement, arrow: arrow, title: content, disableHoverListener: true, disableFocusListener: true, disableTouchListener: true, slotProps: slotProps, children: triggerEl }) }));
57
+ return (_jsx(ClickAwayListener, { onClickAway: (event) => {
58
+ // The panel renders in a portal, so it is not a descendant of the
59
+ // trigger that ClickAwayListener watches — without this check a tap
60
+ // inside the panel counts as "away". That matters on touch, where
61
+ // ClickAwayListener fires on touchend: the panel would unmount before
62
+ // the click reached the item, closing the menu without running its
63
+ // action. Mouse clicks are unaffected either way.
64
+ if (popperRef.current?.contains(event.target))
65
+ return;
66
+ setOpen(false);
67
+ }, children: _jsx(Tooltip, { open: isOpen, placement: placement, arrow: arrow, title: content, disableHoverListener: true, disableFocusListener: true, disableTouchListener: true, slotProps: { ...slotProps, popper: { ref: popperRef } }, children: triggerEl }) }));
57
68
  }
58
69
  const controlledProps = isControlled
59
70
  ? {
@@ -41,7 +41,7 @@ export const SelectAvatarCard = ({ state = SelectAvatarCardState.Filled, showHea
41
41
  opacity: 0,
42
42
  visibility: 'hidden',
43
43
  transition: 'opacity 0.15s ease-in-out',
44
- }, children: _jsx(MainButton, { text: previewButtonText, size: ButtonSize.md, hierarchy: ButtonHierarchy.SecondaryTransparent, showLeadingIcon: true, leadingIcon: "Play", onClick: onPreview, testId: testId ? `${testId}-preview` : undefined }) })), showChangeButton && (_jsx(MainButton, { text: changeButtonText, size: ButtonSize.md, hierarchy: ButtonHierarchy.WhiteText, showLeadingIcon: true, leadingIcon: "ArrowsLeftRight", onClick: onChange, testId: testId ? `${testId}-change` : undefined }))] }), avatarName && (_jsx(Box, { position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", alignItems: "center", padding: "24px 12px 12px", sx: { background: NAME_GRADIENT }, children: _jsx(Typography, { size: TypographySize.HeadingXS, weight: TypographyWeight.SemiBold, color: "var(--color-text-text-white)", textStyle: {
44
+ }, children: _jsx(MainButton, { text: previewButtonText, size: ButtonSize.xxs, hierarchy: ButtonHierarchy.SecondaryTransparent, showLeadingIcon: true, leadingIcon: "Play", onClick: onPreview, testId: testId ? `${testId}-preview` : undefined }) })), showChangeButton && (_jsx(MainButton, { text: changeButtonText, size: ButtonSize.xxs, hierarchy: ButtonHierarchy.WhiteText, showLeadingIcon: true, leadingIcon: "ArrowsLeftRight", onClick: onChange, testId: testId ? `${testId}-change` : undefined }))] }), avatarName && (_jsx(Box, { position: "absolute", left: 0, right: 0, bottom: 0, display: "flex", alignItems: "center", padding: "24px 12px 12px", sx: { background: NAME_GRADIENT }, children: _jsx(Typography, { size: TypographySize.HeadingXS, weight: TypographyWeight.SemiBold, color: "var(--color-text-text-white)", textStyle: {
45
45
  flex: '1 0 0',
46
46
  minWidth: 0,
47
47
  wordBreak: 'break-word',
@@ -49,7 +49,7 @@ export const SelectAvatarCard = ({ state = SelectAvatarCardState.Filled, showHea
49
49
  top: '50%',
50
50
  left: '50%',
51
51
  transform: 'translate(-50%, -50%)',
52
- }, children: _jsx(MainButton, { text: selectButtonText, hierarchy: ButtonHierarchy.PrimaryLemon, size: ButtonSize.xs, shape: ButtonShape.Square, onClick: onSelect, testId: testId ? `${testId}-select` : undefined }) })), isError && (_jsx(Box, { position: "absolute", display: "flex", alignItems: "center", justifyContent: "center", sx: {
52
+ }, children: _jsx(MainButton, { text: selectButtonText, hierarchy: ButtonHierarchy.PrimaryLemon, size: ButtonSize.xxs, shape: ButtonShape.Square, onClick: onSelect, testId: testId ? `${testId}-select` : undefined }) })), isError && (_jsx(Box, { position: "absolute", display: "flex", alignItems: "center", justifyContent: "center", sx: {
53
53
  top: '50%',
54
54
  left: '50%',
55
55
  transform: 'translate(-50%, -50%)',
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useLayoutEffect, useRef, useState } from 'react';
2
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
3
  import { Box } from '@mui/material';
4
4
  import { Typography, TypographySize, TypographyWeight } from '../Typography';
5
5
  import { TabMenuSize } from './types';
@@ -17,7 +17,7 @@ const SIZE_TOKENS = {
17
17
  [TabMenuSize.sm]: {
18
18
  containerGap: 'var(--spacing-tokens-size-in-px-spacing-spacing-xxs)',
19
19
  containerPadding: 'var(--spacing-tokens-size-in-px-spacing-spacing-xxs)',
20
- tabPaddingY: '5px',
20
+ tabPaddingY: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)',
21
21
  tabRadius: 'var(--spacing-tokens-size-in-px-radius-radius-sm)',
22
22
  tabHeight: 'auto',
23
23
  dividerHeight: '20px',
@@ -50,10 +50,38 @@ export const TabMenu = ({ tabs, activeTab = 0, onTabChange, size = TabMenuSize.m
50
50
  height: tab.offsetHeight,
51
51
  });
52
52
  };
53
- measure();
53
+ // When the bar is scrolled (narrow viewports), bring the active tab into
54
+ // view rather than leaving it off-screen.
55
+ const revealActiveTab = () => {
56
+ const tab = tabRefs.current[activeTab];
57
+ if (!tab)
58
+ return;
59
+ // offsetLeft and scrollLeft share an origin — the container's padding box
60
+ // — so the visible window is simply [scrollLeft, scrollLeft + clientWidth].
61
+ const visibleStart = container.scrollLeft;
62
+ const visibleEnd = visibleStart + container.clientWidth;
63
+ // Reveal the container's padding alongside the tab, so an edge tab ends
64
+ // up inset from the border rather than flush against it. Overshooting is
65
+ // safe — the browser clamps scrollLeft to the scrollable range.
66
+ const { paddingLeft, paddingRight } = getComputedStyle(container);
67
+ const tabStart = tab.offsetLeft - (parseFloat(paddingLeft) || 0);
68
+ const tabEnd = tab.offsetLeft + tab.offsetWidth + (parseFloat(paddingRight) || 0);
69
+ const behavior = animated ? 'smooth' : 'auto';
70
+ if (tabStart < visibleStart) {
71
+ container.scrollTo({ left: tabStart, behavior });
72
+ }
73
+ else if (tabEnd > visibleEnd) {
74
+ container.scrollTo({ left: tabEnd - container.clientWidth, behavior });
75
+ }
76
+ };
77
+ const update = () => {
78
+ measure();
79
+ revealActiveTab();
80
+ };
81
+ update();
54
82
  // Re-measure whenever the container or a tab changes size (e.g. responsive
55
83
  // reflow) so the highlight tracks the active tab.
56
- const resizeObserver = new ResizeObserver(measure);
84
+ const resizeObserver = new ResizeObserver(update);
57
85
  resizeObserver.observe(container);
58
86
  tabRefs.current.forEach((tab) => tab && resizeObserver.observe(tab));
59
87
  // Text metrics — and therefore tab widths — change when the fallback font is
@@ -61,14 +89,83 @@ export const TabMenu = ({ tabs, activeTab = 0, onTabChange, size = TabMenuSize.m
61
89
  let cancelled = false;
62
90
  document.fonts?.ready.then(() => {
63
91
  if (!cancelled)
64
- measure();
92
+ update();
65
93
  });
66
94
  return () => {
67
95
  cancelled = true;
68
96
  resizeObserver.disconnect();
69
97
  };
70
- }, [activeTab, size, showDividers, tabs]);
71
- return (_jsxs(Box, { ref: containerRef, "data-testid": testId, position: 'relative', display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: tokens.containerGap, padding: tokens.containerPadding, bgcolor: 'var(--color-background-bg-component)', border: '1px solid var(--color-border-border-component)', borderRadius: 'var(--spacing-tokens-size-in-px-radius-radius-md)', sx: { boxSizing: 'border-box', ...style }, children: [indicator && (_jsx(Box, { "aria-hidden": true, position: 'absolute', bgcolor: 'var(--color-background-bg-brand-transparent)', borderRadius: tokens.tabRadius, sx: {
98
+ }, [activeTab, animated, size, showDividers, tabs]);
99
+ // A mouse wheel only emits deltaY, which a horizontal-only scroller ignores
100
+ // and with the scrollbar hidden that leaves mouse users unable to reach tabs
101
+ // that are off-screen. Translate vertical wheel into horizontal scroll, but
102
+ // only while the bar overflows and only while it can still move that way, so
103
+ // the page keeps scrolling normally at the ends.
104
+ useEffect(() => {
105
+ const container = containerRef.current;
106
+ if (!container)
107
+ return;
108
+ const handleWheel = (event) => {
109
+ const maxScroll = container.scrollWidth - container.clientWidth;
110
+ if (maxScroll <= 0)
111
+ return;
112
+ // Leave genuine horizontal gestures (trackpad swipe) to the browser.
113
+ if (Math.abs(event.deltaY) <= Math.abs(event.deltaX))
114
+ return;
115
+ const atStart = container.scrollLeft <= 0;
116
+ const atEnd = container.scrollLeft >= maxScroll - 1;
117
+ if ((event.deltaY < 0 && atStart) || (event.deltaY > 0 && atEnd))
118
+ return;
119
+ event.preventDefault();
120
+ container.scrollLeft += event.deltaY;
121
+ };
122
+ // Not passive: we need preventDefault to stop the page scrolling with us.
123
+ container.addEventListener('wheel', handleWheel, { passive: false });
124
+ return () => container.removeEventListener('wheel', handleWheel);
125
+ }, []);
126
+ // Arrow keys move between tabs and select as they go (automatic activation),
127
+ // Home/End jump to the ends. Selection wraps at both edges. Focus is moved
128
+ // with preventScroll so it doesn't fight the smooth scroll in the effect.
129
+ const handleKeyDown = (event) => {
130
+ if (!onTabChange)
131
+ return;
132
+ const lastIndex = tabs.length - 1;
133
+ let nextIndex;
134
+ switch (event.key) {
135
+ case 'ArrowRight':
136
+ nextIndex = activeTab >= lastIndex ? 0 : activeTab + 1;
137
+ break;
138
+ case 'ArrowLeft':
139
+ nextIndex = activeTab <= 0 ? lastIndex : activeTab - 1;
140
+ break;
141
+ case 'Home':
142
+ nextIndex = 0;
143
+ break;
144
+ case 'End':
145
+ nextIndex = lastIndex;
146
+ break;
147
+ default:
148
+ return;
149
+ }
150
+ event.preventDefault();
151
+ onTabChange(nextIndex);
152
+ tabRefs.current[nextIndex]?.focus({ preventScroll: true });
153
+ };
154
+ return (_jsxs(Box, { ref: containerRef, "data-testid": testId, role: 'tablist', onKeyDown: handleKeyDown, position: 'relative', display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: tokens.containerGap, padding: tokens.containerPadding, bgcolor: 'var(--color-background-bg-navigate)', border: '1px solid var(--color-border-border-component)', borderRadius: 'var(--spacing-tokens-size-in-px-radius-radius-md)', sx: {
155
+ boxSizing: 'border-box',
156
+ // Tabs keep their natural width (they never shrink), so on narrow
157
+ // viewports the bar scrolls sideways instead of overflowing its parent.
158
+ maxWidth: '100%',
159
+ overflowX: 'auto',
160
+ // Don't let a horizontal swipe past the end trigger back-navigation.
161
+ overscrollBehaviorX: 'contain',
162
+ // This reads as a segmented control, not a scroll region, so the
163
+ // scrollbar chrome is hidden — touch, trackpad and the
164
+ // keep-the-active-tab-visible logic below still scroll it.
165
+ scrollbarWidth: 'none',
166
+ '&::-webkit-scrollbar': { display: 'none' },
167
+ ...style,
168
+ }, children: [indicator && (_jsx(Box, { "aria-hidden": true, position: 'absolute', bgcolor: 'var(--color-background-bg-brand-transparent)', borderRadius: tokens.tabRadius, sx: {
72
169
  left: 0,
73
170
  top: 0,
74
171
  width: indicator.width,
@@ -84,10 +181,18 @@ export const TabMenu = ({ tabs, activeTab = 0, onTabChange, size = TabMenuSize.m
84
181
  const isActive = index === activeTab;
85
182
  return (_jsxs(React.Fragment, { children: [showDividers && index > 0 && (_jsx(Box, { width: '1px', height: tokens.dividerHeight, bgcolor: 'var(--color-border-border-divider)', flexShrink: 0, sx: { zIndex: 1 } })), _jsx(Box, { ref: (el) => {
86
183
  tabRefs.current[index] = el;
87
- }, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', position: 'relative', flexShrink: 0, height: tokens.tabHeight, paddingX: 'var(--spacing-tokens-size-in-px-spacing-spacing-4xl)', paddingY: tokens.tabPaddingY, borderRadius: tokens.tabRadius, onClick: onTabChange ? () => onTabChange(index) : undefined, sx: {
184
+ }, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', position: 'relative', flexShrink: 0, height: tokens.tabHeight, paddingX: 'var(--spacing-tokens-size-in-px-spacing-spacing-4xl)', paddingY: tokens.tabPaddingY, borderRadius: tokens.tabRadius, role: 'tab', "aria-selected": isActive,
185
+ // Roving tabindex: Tab reaches the active tab, arrow keys move
186
+ // within the list. Non-interactive menus stay out of the tab order.
187
+ tabIndex: onTabChange ? (isActive ? 0 : -1) : undefined, onClick: onTabChange ? () => onTabChange(index) : undefined, sx: {
88
188
  boxSizing: 'border-box',
89
189
  zIndex: 1,
90
190
  cursor: onTabChange ? 'pointer' : 'default',
191
+ // Inset so the ring isn't clipped by the scroll container.
192
+ '&:focus-visible': {
193
+ outline: '2px solid var(--color-border-border-component-active)',
194
+ outlineOffset: '-2px',
195
+ },
91
196
  '&:hover': onTabChange && !isActive
92
197
  ? {
93
198
  bgcolor: 'var(--color-background-bg-brand-transparent-hover)',
@@ -97,7 +202,7 @@ export const TabMenu = ({ tabs, activeTab = 0, onTabChange, size = TabMenuSize.m
97
202
  ...tabStyle,
98
203
  }, children: _jsx(Typography, { size: tokens.textSize, weight: tokens.textWeight, color: isActive
99
204
  ? 'var(--color-text-text-clicable)'
100
- : 'var(--color-text-text-subtle)', textStyle: {
205
+ : 'var(--color-text-text-label)', textStyle: {
101
206
  transition: animated ? 'color 0.25s ease' : 'none',
102
207
  }, children: label }) })] }, index));
103
208
  })] }));
@@ -7,3 +7,9 @@ export declare const Medium: Story;
7
7
  export declare const Small: Story;
8
8
  export declare const MediumStatic: Story;
9
9
  export declare const SmallStatic: Story;
10
+ /**
11
+ * Five tabs need far more width than a 320px phone offers, so the bar scrolls
12
+ * sideways rather than overflowing its container. Selecting a tab that sits
13
+ * off-screen scrolls it back into view.
14
+ */
15
+ export declare const MobileScrolling: Story;
@@ -70,7 +70,13 @@ const meta = {
70
70
  },
71
71
  };
72
72
  export default meta;
73
- const TABS = ['Tab name', 'Tab name', 'Tab name', 'Tab name', 'Tab name'];
73
+ const TABS = [
74
+ 'Tab name 1',
75
+ 'Tab name 2',
76
+ 'Tab name 3',
77
+ 'Tab name 4',
78
+ 'Tab name 5',
79
+ ];
74
80
  const InteractiveTabMenu = (args) => {
75
81
  const [activeTab, setActiveTab] = useState(args.activeTab ?? 0);
76
82
  return _jsx(TabMenu, { ...args, activeTab: activeTab, onTabChange: setActiveTab });
@@ -108,3 +114,19 @@ export const SmallStatic = {
108
114
  animated: false,
109
115
  },
110
116
  };
117
+ /**
118
+ * Five tabs need far more width than a 320px phone offers, so the bar scrolls
119
+ * sideways rather than overflowing its container. Selecting a tab that sits
120
+ * off-screen scrolls it back into view.
121
+ */
122
+ export const MobileScrolling = {
123
+ render: (args) => _jsx(InteractiveTabMenu, { ...args }),
124
+ args: {
125
+ ...Medium.args,
126
+ size: TabMenuSize.sm,
127
+ },
128
+ parameters: {
129
+ layout: 'padded',
130
+ viewport: { defaultViewport: 'mobile1' },
131
+ },
132
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beydesign/storybook",
3
3
  "private": false,
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",