@beydesign/storybook 0.4.3 → 0.4.5

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.
@@ -52,18 +52,32 @@ const ICON_ONLY_SIZE = {
52
52
  [ButtonSize.xl]: 52,
53
53
  },
54
54
  };
55
+ // Figma sizes a text button by its height, with the label centred inside, so
56
+ // take the height from the design rather than deriving it from padding: a
57
+ // CSS border sits outside the box where a Figma stroke sits inside, which
58
+ // would otherwise leave every button 2px taller than the design.
59
+ const TEXT_BUTTON_HEIGHT = {
60
+ [ButtonSize.xxs]: 32,
61
+ [ButtonSize.xs]: 36,
62
+ [ButtonSize.sm]: 40,
63
+ [ButtonSize.md]: 44,
64
+ [ButtonSize.lg]: 48,
65
+ [ButtonSize.xl]: 52,
66
+ };
55
67
  const StyledButton = styled.button `
56
68
  text-transform: none;
57
69
  box-sizing: border-box;
58
- padding: ${(props) => props.$displayIconMode === ButtonDisplayMode.Only
59
- ? '0'
60
- : props.$sizeStyles.padding};
61
70
  ${(props) => {
62
- if (props.$displayIconMode !== ButtonDisplayMode.Only)
63
- return '';
64
- const box = `${ICON_ONLY_SIZE[props.$shape][props.$size]}px`;
65
- return `width: ${box};
66
- height: ${box};`;
71
+ if (props.$displayIconMode === ButtonDisplayMode.Only) {
72
+ const box = `${ICON_ONLY_SIZE[props.$shape][props.$size]}px`;
73
+ return `padding: 0;
74
+ width: ${box};
75
+ height: ${box};`;
76
+ }
77
+ // min-height, not height, so a label that ever wraps grows the button
78
+ // instead of spilling out of it.
79
+ return `padding: ${props.$sizeStyles.padding};
80
+ min-height: ${TEXT_BUTTON_HEIGHT[props.$size]}px;`;
67
81
  }}
68
82
  font-size: ${(props) => props.$sizeStyles.fontSize};
69
83
  border-radius: ${(props) => props.$shape === ButtonShape.Square
@@ -237,36 +251,41 @@ const StyledButton = styled.button `
237
251
  export const MainButton = ({ onClick, text, disabled = false, loading = false, type = ButtonType.Button, style, size = ButtonSize.xl, hierarchy = ButtonHierarchy.Primary, displayIcon, iconWeight = 'regular', displayIconMode = ButtonDisplayMode.Default, state = ButtonState.Default, shape = ButtonShape.Square, destructive = false, showLeadingIcon = false, showTrailingIcon = false, leadingIcon = 'Circle', trailingIcon = 'Circle', testId, }) => {
238
252
  // Figma draws a 20px icon at every button size.
239
253
  const iconSize = 20;
240
- // Icon-only buttons take their box from ICON_ONLY_SIZE and ignore padding.
254
+ // Vertical padding comes from TEXT_BUTTON_HEIGHT; only the horizontal value
255
+ // is set here. Icon-only buttons take their box from ICON_ONLY_SIZE and
256
+ // ignore padding entirely.
257
+ //
258
+ // Figma leaves the sm, lg and xl horizontal padding, and the xl gap, off the
259
+ // spacing scale, so those are raw values with no token to point at.
241
260
  const getButtonSize = () => {
242
261
  const sizes = {
243
262
  [ButtonSize.xl]: {
244
- padding: 'var(--spacing-tokens-size-in-px-spacing-spacing-lg) var(--spacing-tokens-size-in-px-spacing-spacing-3xl)',
263
+ padding: '0 22px',
245
264
  fontSize: 'var(--global-font-size-3)',
246
- gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)',
265
+ gap: '10px',
247
266
  },
248
267
  [ButtonSize.lg]: {
249
- padding: 'var(--spacing-tokens-size-in-px-spacing-spacing-lg) var(--spacing-tokens-size-in-px-spacing-spacing-2xl)',
268
+ padding: '0 18px',
250
269
  fontSize: 'var(--global-font-size-3)',
251
270
  gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)',
252
271
  },
253
272
  [ButtonSize.md]: {
254
- padding: 'var(--spacing-tokens-size-in-px-spacing-spacing-md) var(--spacing-tokens-size-in-px-spacing-spacing-xl)',
273
+ padding: '0 var(--spacing-tokens-size-in-px-spacing-spacing-xl)',
255
274
  fontSize: 'var(--global-font-size-2)',
256
275
  gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm)',
257
276
  },
258
277
  [ButtonSize.sm]: {
259
- padding: 'var(--spacing-tokens-size-in-px-spacing-spacing-md) var(--spacing-tokens-size-in-px-spacing-spacing-xl)',
278
+ padding: '0 14px',
260
279
  fontSize: 'var(--global-font-size-1)',
261
280
  gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-xs)',
262
281
  },
263
282
  [ButtonSize.xs]: {
264
- padding: 'var(--spacing-tokens-size-in-px-spacing-spacing-md) var(--spacing-tokens-size-in-px-spacing-spacing-lg)',
283
+ padding: '0 var(--spacing-tokens-size-in-px-spacing-spacing-lg)',
265
284
  fontSize: 'var(--global-font-size-1)',
266
285
  gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-xs)',
267
286
  },
268
287
  [ButtonSize.xxs]: {
269
- padding: 'var(--spacing-tokens-size-in-px-spacing-spacing-sm) var(--spacing-tokens-size-in-px-spacing-spacing-md)',
288
+ padding: '0 var(--spacing-tokens-size-in-px-spacing-spacing-md)',
270
289
  fontSize: 'var(--global-font-size-0)',
271
290
  gap: 'var(--spacing-tokens-size-in-px-spacing-spacing-xs)',
272
291
  },
@@ -7,6 +7,9 @@ import { NavPanel } from '../internal';
7
7
  import { getIconComponent } from '../../../utils';
8
8
  const EXPANDED_WIDTH = '238px';
9
9
  const COLLAPSED_WIDTH = '68px';
10
+ /** Mirrors the horizontal padding `NavPanel` applies around its content. */
11
+ const PANEL_PADDING_X = 'var(--spacing-tokens-size-in-px-spacing-spacing-xl, 16px)';
12
+ const contentWidth = (panelWidth) => `calc(${panelWidth} - 2 * ${PANEL_PADDING_X})`;
10
13
  /** Composed from the semantic `shadow-sm` design token. */
11
14
  const SHADOW_SM = 'var(--global-shadows-shadow-sm-offset-x) var(--global-shadows-shadow-sm-offset-y) var(--global-shadows-shadow-sm-blur) var(--global-shadows-shadow-sm-spread) var(--global-shadows-shadow-sm-color)';
12
15
  /**
@@ -21,6 +24,21 @@ const Shell = styled.aside `
21
24
  width: ${({ $collapsed }) => ($collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH)};
22
25
  transition: width 0.3s ease-in-out;
23
26
  `;
27
+ /**
28
+ * Holds the body at the width its state will settle at, rather than letting it
29
+ * fill the shell mid-animation. Without this the expanded layout reflows inside
30
+ * a shell that is still collapsed — labels squeeze to an ellipsis and the
31
+ * credits caption wraps down the card — before snapping into place. The panel
32
+ * clips the overhang (`overflow-x: hidden`) and reveals it as the width catches
33
+ * up.
34
+ */
35
+ const Content = styled.div `
36
+ display: flex;
37
+ flex-direction: column;
38
+ flex: 1;
39
+ min-height: 0;
40
+ width: ${({ $collapsed }) => contentWidth($collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH)};
41
+ `;
24
42
  const ToggleButton = styled.button `
25
43
  appearance: none;
26
44
  position: absolute;
@@ -77,5 +95,5 @@ export const Drawer = ({ navItems, activeKey, collapsed, defaultCollapsed = fals
77
95
  setInternalCollapsed(next);
78
96
  onToggle?.(next);
79
97
  };
80
- return (_jsxs(Shell, { "$collapsed": isCollapsed, className: className, style: style, "data-testid": testId, children: [_jsx(NavPanel, { children: _jsx(DrawerBody, { navItems: navItems, activeKey: activeKey, size: drawerSize, credits: credits, creditsLoading: creditsLoading, profile: profile, profileLoading: profileLoading, logo: logo }) }), showToggle && (_jsx(ToggleButton, { type: "button", onClick: handleToggle, "aria-label": isCollapsed ? 'Expand sidebar' : 'Collapse sidebar', children: _jsx(ToggleIcon, { "$collapsed": isCollapsed, children: getIconComponent('CaretLeft', { size: 16, weight: 'bold' }) }) }))] }));
98
+ return (_jsxs(Shell, { "$collapsed": isCollapsed, className: className, style: style, "data-testid": testId, children: [_jsx(NavPanel, { children: _jsx(Content, { "$collapsed": isCollapsed, children: _jsx(DrawerBody, { navItems: navItems, activeKey: activeKey, size: drawerSize, credits: credits, creditsLoading: creditsLoading, profile: profile, profileLoading: profileLoading, logo: logo }) }) }), showToggle && (_jsx(ToggleButton, { type: "button", onClick: handleToggle, "aria-label": isCollapsed ? 'Expand sidebar' : 'Collapse sidebar', children: _jsx(ToggleIcon, { "$collapsed": isCollapsed, children: getIconComponent('CaretLeft', { size: 16, weight: 'bold' }) }) }))] }));
81
99
  };
@@ -3,8 +3,9 @@ import { DrawerProfileProps } from './types';
3
3
  /**
4
4
  * User profile footer for the {@link Drawer}: avatar plus name and email.
5
5
  * Collapses to just the avatar. When `menuItems` are supplied, clicking the
6
- * profile drops an inline menu directly below the row (e.g. a "Log out"
7
- * action), styled like the drawer's nav items so it reads as part of the drawer
8
- * rather than a floating overlay. The profile row itself stays in place.
6
+ * profile opens a popover menu anchored to the row (e.g. a "Log out" action)
7
+ * rather than expanding the drawer footer, so the drawer's layout never shifts.
8
+ * The menu always shows its labels including when the drawer is collapsed and
9
+ * the popover opens to the side of the avatar.
9
10
  */
10
11
  export declare const DrawerProfile: React.FC<DrawerProfileProps>;
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useEffect, useRef, useState } from 'react';
3
3
  import styled from '@emotion/styled';
4
4
  import { DrawerSize } from './types';
5
- import { Avatar, AvatarSize, AvatarStyle } from '../../UI';
5
+ import { Avatar, AvatarSize, AvatarStyle, Popover, PopoverVariant, } from '../../UI';
6
6
  import { Typography, TypographySize, TypographyWeight } from '../../Typography';
7
7
  import { getIconComponent } from '../../../utils';
8
8
  const Container = styled.div `
@@ -54,31 +54,31 @@ const Chevron = styled.span `
54
54
  transition: none;
55
55
  }
56
56
  `;
57
+ /** The popover's border sits outside this list, on the surface around it. */
58
+ const POPOVER_BORDER = 2;
57
59
  /**
58
- * Collapsible wrapper for the menu that drops below the profile row. Animates
59
- * height with the `grid-template-rows: 0fr -> 1fr` trick so it reveals/hides
60
- * smoothly without measuring the content.
60
+ * MUI puts a 2px margin on each side of the tooltip surface, which pushed the
61
+ * menu 2px right of the profile row. Drop the side margins so the two line up;
62
+ * the bottom margin is the gap to the row, so it stays.
61
63
  */
62
- const Menu = styled.div `
63
- display: grid;
64
- grid-template-rows: ${({ $open }) => ($open ? '1fr' : '0fr')};
65
- transition: grid-template-rows 0.25s ease-in-out;
66
-
67
- @media (prefers-reduced-motion: reduce) {
68
- transition: none;
64
+ const MenuPopover = styled(Popover) `
65
+ && {
66
+ margin-left: 0;
67
+ margin-right: 0;
69
68
  }
70
69
  `;
71
- const MenuClip = styled.div `
72
- overflow: hidden;
73
- min-height: 0;
74
- `;
70
+ /**
71
+ * Menu surface rendered inside the popover. Owns its own padding — the popover
72
+ * is handed `padding="0"` — so that pinning its width lines the menu up with
73
+ * the profile row exactly, with no surface padding left to widen it.
74
+ */
75
75
  const MenuList = styled.div `
76
76
  display: flex;
77
77
  flex-direction: column;
78
78
  gap: var(--spacing-tokens-size-in-px-spacing-spacing-xxs, 2px);
79
- padding-top: var(--spacing-tokens-size-in-px-spacing-spacing-xs, 4px);
80
- margin-top: var(--spacing-tokens-size-in-px-spacing-spacing-xs, 4px);
81
- border-top: 1px solid var(--color-border-border-subtle);
79
+ box-sizing: border-box;
80
+ padding: var(--spacing-tokens-size-in-px-spacing-spacing-xs, 4px);
81
+ min-width: 200px;
82
82
  `;
83
83
  /** Menu rows reuse the nav-item look so they read as part of the drawer. */
84
84
  const MenuItemButton = styled.button `
@@ -90,7 +90,7 @@ const MenuItemButton = styled.button `
90
90
  gap: var(--spacing-tokens-size-in-px-spacing-spacing-md, 8px);
91
91
  width: 100%;
92
92
  box-sizing: border-box;
93
- justify-content: ${({ $collapsed }) => $collapsed ? 'center' : 'flex-start'};
93
+ justify-content: flex-start;
94
94
  padding: var(--spacing-tokens-size-in-px-spacing-spacing-md, 8px);
95
95
  border-radius: var(--spacing-tokens-size-in-px-radius-radius-xs);
96
96
  font: inherit;
@@ -113,58 +113,62 @@ const MenuItemButton = styled.button `
113
113
  /**
114
114
  * User profile footer for the {@link Drawer}: avatar plus name and email.
115
115
  * Collapses to just the avatar. When `menuItems` are supplied, clicking the
116
- * profile drops an inline menu directly below the row (e.g. a "Log out"
117
- * action), styled like the drawer's nav items so it reads as part of the drawer
118
- * rather than a floating overlay. The profile row itself stays in place.
116
+ * profile opens a popover menu anchored to the row (e.g. a "Log out" action)
117
+ * rather than expanding the drawer footer, so the drawer's layout never shifts.
118
+ * The menu always shows its labels including when the drawer is collapsed and
119
+ * the popover opens to the side of the avatar.
119
120
  */
120
121
  export const DrawerProfile = ({ size = DrawerSize.Expanded, name, email, avatarImage, avatarText, menuItems, onClick, className, style, testId, }) => {
121
122
  const isCollapsed = size === DrawerSize.Collapsed;
122
123
  const hasMenu = Boolean(menuItems && menuItems.length > 0);
123
124
  const [open, setOpen] = useState(false);
124
125
  const containerRef = useRef(null);
125
- // Close the inline menu on outside click or Escape.
126
+ const [menuWidth, setMenuWidth] = useState();
127
+ // Match the menu to the profile row so the two line up. Only when expanded —
128
+ // collapsed, the row is just the avatar and the menu sizes to its content.
129
+ useEffect(() => {
130
+ if (!open || isCollapsed)
131
+ return;
132
+ const width = containerRef.current?.offsetWidth;
133
+ if (width)
134
+ setMenuWidth(width - POPOVER_BORDER);
135
+ }, [open, isCollapsed]);
136
+ // Outside clicks are handled by the Popover itself; Escape is not.
126
137
  useEffect(() => {
127
138
  if (!open)
128
139
  return;
129
- const handlePointer = (event) => {
130
- if (!containerRef.current?.contains(event.target)) {
131
- setOpen(false);
132
- }
133
- };
134
140
  const handleKey = (event) => {
135
141
  if (event.key === 'Escape')
136
142
  setOpen(false);
137
143
  };
138
- document.addEventListener('mousedown', handlePointer);
139
144
  document.addEventListener('keydown', handleKey);
140
- return () => {
141
- document.removeEventListener('mousedown', handlePointer);
142
- document.removeEventListener('keydown', handleKey);
143
- };
145
+ return () => document.removeEventListener('keydown', handleKey);
144
146
  }, [open]);
145
147
  const clickable = hasMenu || Boolean(onClick);
146
- const handleRowClick = () => {
147
- if (hasMenu) {
148
- setOpen((prev) => !prev);
149
- }
150
- else {
151
- onClick?.();
152
- }
153
- };
154
148
  const handleItemClick = (item) => {
155
149
  setOpen(false);
156
150
  item.onClick?.();
157
151
  };
158
- return (_jsxs(Container, { ref: containerRef, className: className, style: style, "data-testid": testId, children: [_jsxs(Row, { "$collapsed": isCollapsed, "$clickable": clickable, "$active": hasMenu && open, onClick: handleRowClick, role: clickable ? 'button' : undefined, tabIndex: clickable ? 0 : undefined, "aria-expanded": hasMenu ? open : undefined, onKeyDown: clickable
159
- ? (event) => {
160
- if (event.key === 'Enter' || event.key === ' ') {
161
- event.preventDefault();
162
- handleRowClick();
163
- }
164
- }
165
- : undefined, children: [_jsx(Avatar, { size: AvatarSize.sm, border: true, avatarStyle: avatarImage ? AvatarStyle.Picture : AvatarStyle.Letters, image: avatarImage, text: avatarText ?? name }), !isCollapsed && (_jsxs(_Fragment, { children: [_jsxs(TextColumn, { children: [_jsx(TextLine, { children: _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.SemiBold, color: "var(--color-text-text-title)", children: name }) }), email && (_jsx(TextLine, { children: _jsx(Typography, { size: TypographySize.TextXS, weight: TypographyWeight.Regular, color: "var(--color-text-text-paragraph)", children: email }) }))] }), hasMenu && (_jsx(Chevron, { "$open": open, children: getIconComponent('CaretDown', { size: 16, weight: 'bold' }) }))] }))] }), hasMenu && (_jsx(Menu, { "$open": open, "aria-hidden": !open, children: _jsx(MenuClip, { children: _jsx(MenuList, { role: "menu", children: menuItems?.map((item, index) => (_jsxs(MenuItemButton, { type: "button", role: "menuitem", tabIndex: open ? 0 : -1, "$destructive": Boolean(item.destructive), "$collapsed": isCollapsed, title: isCollapsed ? item.label : undefined, onClick: () => handleItemClick(item), children: [item.icon &&
166
- getIconComponent(item.icon, {
167
- size: 20,
168
- color: 'currentColor',
169
- }), !isCollapsed && (_jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: "inherit", children: item.label }))] }, index))) }) }) }))] }));
152
+ const row = (_jsxs(Row, { "$collapsed": isCollapsed, "$clickable": clickable, "$active": hasMenu && open,
153
+ // With a menu the Popover owns the click; otherwise fire the row handler.
154
+ onClick: hasMenu ? undefined : onClick, role: clickable ? 'button' : undefined, tabIndex: clickable ? 0 : undefined, "aria-haspopup": hasMenu ? 'menu' : undefined, "aria-expanded": hasMenu ? open : undefined, onKeyDown: clickable
155
+ ? (event) => {
156
+ if (event.key === 'Enter' || event.key === ' ') {
157
+ event.preventDefault();
158
+ if (hasMenu)
159
+ setOpen((prev) => !prev);
160
+ else
161
+ onClick?.();
162
+ }
163
+ }
164
+ : undefined, children: [_jsx(Avatar, { size: AvatarSize.sm, border: true, avatarStyle: avatarImage ? AvatarStyle.Picture : AvatarStyle.Letters, image: avatarImage, text: avatarText ?? name }), !isCollapsed && (_jsxs(_Fragment, { children: [_jsxs(TextColumn, { children: [_jsx(TextLine, { children: _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.SemiBold, color: "var(--color-text-text-title)", children: name }) }), email && (_jsx(TextLine, { children: _jsx(Typography, { size: TypographySize.TextXS, weight: TypographyWeight.Regular, color: "var(--color-text-text-paragraph)", children: email }) }))] }), hasMenu && (_jsx(Chevron, { "$open": open, children: getIconComponent('CaretDown', { size: 16, weight: 'bold' }) }))] }))] }));
165
+ const menu = (_jsx(MenuList, { role: "menu", style: isCollapsed ? undefined : { width: menuWidth, minWidth: 0 }, children: menuItems?.map((item, index) => (_jsxs(MenuItemButton, { type: "button", role: "menuitem", "$destructive": Boolean(item.destructive), onClick: () => handleItemClick(item), children: [item.icon &&
166
+ getIconComponent(item.icon, {
167
+ size: 20,
168
+ color: 'currentColor',
169
+ }), _jsx(Typography, { size: TypographySize.TextS, weight: TypographyWeight.Regular, color: "inherit", children: item.label })] }, index))) }));
170
+ return (_jsx(Container, { ref: containerRef, className: className, style: style, "data-testid": testId, children: hasMenu ? (_jsx(MenuPopover, { content: menu, variant: PopoverVariant.Light,
171
+ // Anchored above the row so the menu never covers the profile; to the
172
+ // side when collapsed, where there is no room above the avatar.
173
+ placement: isCollapsed ? 'right-end' : 'top-start', trigger: "click", arrow: false, maxWidth: isCollapsed ? 260 : menuWidth, padding: "0", open: open, onOpenChange: setOpen, children: row })) : (row) }));
170
174
  };
@@ -100,8 +100,8 @@ export interface DrawerProfileProps {
100
100
  /** Overrides the initials source (defaults to `name`). */
101
101
  avatarText?: string;
102
102
  /**
103
- * When provided, clicking the profile drops an inline menu with these items
104
- * (e.g. a "Log out" action) directly below the profile row instead of firing
103
+ * When provided, clicking the profile opens a popover menu with these items
104
+ * (e.g. a "Log out" action) anchored to the profile row instead of firing
105
105
  * `onClick`.
106
106
  */
107
107
  menuItems?: DrawerProfileMenuItem[];
@@ -37,10 +37,15 @@ const StyledNavRow = styled.button `
37
37
  background-color: ${({ $state }) => background($state)};
38
38
  opacity: ${({ $state }) => ($state === NavItemState.Disabled ? 0.5 : 1)};
39
39
  cursor: ${({ $state }) => $state === NavItemState.Disabled ? 'not-allowed' : 'pointer'};
40
+ /*
41
+ * Padding deliberately does not transition. The icon lands on the same x in
42
+ * both states, so animating it gained nothing visually — but since the row is
43
+ * auto height once expanded, growing the padding from 0 pumped every row from
44
+ * the bare icon height back up to full, shifting the list as it went.
45
+ */
40
46
  transition:
41
47
  background-color 0.2s ease-in-out,
42
- color 0.2s ease-in-out,
43
- padding 0.3s ease-in-out;
48
+ color 0.2s ease-in-out;
44
49
 
45
50
  &:hover {
46
51
  ${({ $state }) => $state === NavItemState.Inactive
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beydesign/storybook",
3
3
  "private": false,
4
- "version": "0.4.3",
4
+ "version": "0.4.5",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",