@estiva-app/ui 0.23.0 → 0.24.1

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.
Files changed (120) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/Form.d.ts.map +1 -1
  4. package/dist/IconButton.d.ts +9 -1
  5. package/dist/IconButton.d.ts.map +1 -1
  6. package/dist/ListColumn.d.ts +41 -0
  7. package/dist/ListColumn.d.ts.map +1 -0
  8. package/dist/PreviewCard.d.ts +9 -2
  9. package/dist/PreviewCard.d.ts.map +1 -1
  10. package/dist/SectionHeader.d.ts +7 -1
  11. package/dist/SectionHeader.d.ts.map +1 -1
  12. package/dist/Select.d.ts +12 -3
  13. package/dist/Select.d.ts.map +1 -1
  14. package/dist/Toolbar.d.ts +18 -0
  15. package/dist/Toolbar.d.ts.map +1 -1
  16. package/dist/Tooltip.d.ts +13 -1
  17. package/dist/Tooltip.d.ts.map +1 -1
  18. package/dist/eslint/index.js +2 -1
  19. package/dist/eslint/index.js.map +2 -2
  20. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +189 -143
  24. package/dist/index.js.map +4 -4
  25. package/package.json +1 -1
  26. package/registry.json +214 -11
  27. package/src/AppShell.mdx +18 -1
  28. package/src/AppShell.test.tsx +16 -0
  29. package/src/AppShell.tsx +4 -1
  30. package/src/AttachmentCard.mdx +12 -1
  31. package/src/AttachmentCard.test.tsx +10 -0
  32. package/src/AttachmentCard.tsx +8 -5
  33. package/src/Avatar.mdx +20 -5
  34. package/src/AvatarGroup.mdx +4 -0
  35. package/src/Banner.mdx +17 -8
  36. package/src/Breadcrumb.mdx +12 -0
  37. package/src/Button.mdx +17 -5
  38. package/src/Button.tsx +2 -2
  39. package/src/Card.mdx +14 -1
  40. package/src/Checkbox.mdx +11 -0
  41. package/src/Chip.mdx +4 -0
  42. package/src/ChipInput.mdx +13 -1
  43. package/src/CollapsibleSection.mdx +12 -3
  44. package/src/CommandPalette.mdx +14 -2
  45. package/src/ConfirmDialog.mdx +13 -2
  46. package/src/ContainerHeader.mdx +15 -2
  47. package/src/DialogShell.mdx +15 -1
  48. package/src/Divider.mdx +12 -1
  49. package/src/EditableText.mdx +17 -1
  50. package/src/EmptyState.mdx +17 -6
  51. package/src/Field.mdx +13 -5
  52. package/src/FieldLine.mdx +9 -1
  53. package/src/FilePicker.mdx +8 -0
  54. package/src/Form.mdx +11 -1
  55. package/src/Form.test.tsx +26 -0
  56. package/src/Form.tsx +7 -0
  57. package/src/IconButton.mdx +17 -3
  58. package/src/IconButton.stories.tsx +3 -0
  59. package/src/IconButton.test.tsx +25 -0
  60. package/src/IconButton.tsx +61 -46
  61. package/src/IdentityMenu.mdx +10 -1
  62. package/src/InlineChip.mdx +11 -0
  63. package/src/Kbd.mdx +6 -2
  64. package/src/Link.mdx +13 -1
  65. package/src/ListColumn.mdx +87 -0
  66. package/src/ListColumn.stories.tsx +136 -0
  67. package/src/ListColumn.test.tsx +50 -0
  68. package/src/ListColumn.tsx +63 -0
  69. package/src/Menu.mdx +18 -1
  70. package/src/MenuItem.mdx +15 -2
  71. package/src/NavItem.mdx +12 -0
  72. package/src/Person.mdx +7 -0
  73. package/src/PersonTrigger.mdx +11 -1
  74. package/src/PersonTrigger.tsx +1 -1
  75. package/src/Popover.mdx +23 -0
  76. package/src/PreviewCard.mdx +19 -4
  77. package/src/PreviewCard.tsx +11 -4
  78. package/src/ProgressBar.mdx +8 -0
  79. package/src/Property.mdx +4 -0
  80. package/src/Rail.mdx +12 -1
  81. package/src/RailItem.mdx +12 -0
  82. package/src/RailItem.tsx +1 -1
  83. package/src/Reaction.mdx +10 -0
  84. package/src/Reaction.tsx +1 -1
  85. package/src/ReactionPicker.mdx +8 -0
  86. package/src/ScrollArea.mdx +16 -2
  87. package/src/SearchInput.mdx +9 -0
  88. package/src/SectionHeader.mdx +18 -1
  89. package/src/SectionHeader.stories.tsx +9 -0
  90. package/src/SectionHeader.test.tsx +9 -0
  91. package/src/SectionHeader.tsx +9 -3
  92. package/src/SectionLabel.mdx +10 -2
  93. package/src/Select.mdx +24 -4
  94. package/src/Select.stories.tsx +4 -1
  95. package/src/Select.test.tsx +29 -0
  96. package/src/Select.tsx +36 -14
  97. package/src/Sidebar.mdx +8 -0
  98. package/src/Skeleton.mdx +8 -0
  99. package/src/Skeleton.tsx +1 -1
  100. package/src/Tabs.mdx +12 -1
  101. package/src/TextInput.mdx +9 -0
  102. package/src/TextInput.tsx +1 -1
  103. package/src/Textarea.mdx +8 -0
  104. package/src/Toast.mdx +12 -1
  105. package/src/Toolbar.mdx +20 -2
  106. package/src/Toolbar.stories.tsx +19 -2
  107. package/src/Toolbar.test.tsx +24 -1
  108. package/src/Toolbar.tsx +22 -0
  109. package/src/Tooltip.mdx +27 -2
  110. package/src/Tooltip.stories.tsx +26 -0
  111. package/src/Tooltip.test.tsx +51 -0
  112. package/src/Tooltip.tsx +17 -4
  113. package/src/TopBar.mdx +11 -0
  114. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  115. package/src/eslint/no-restyled-part.ts +1 -0
  116. package/src/heights.test.tsx +78 -0
  117. package/src/index.ts +2 -1
  118. package/src/pages.test.ts +142 -0
  119. package/src/registry/registry.test.ts +16 -8
  120. package/tailwind-preset.js +22 -1
@@ -28,6 +28,31 @@ describe('IconButton', () => {
28
28
  expect(onClick).toHaveBeenCalledTimes(1)
29
29
  })
30
30
 
31
+ it('href: a link that looks like the button, with its tooltip', async () => {
32
+ const user = userEvent.setup()
33
+ render(
34
+ <IconButton href="/documents/12" aria-label="Open" tooltip="Open">
35
+ {icon}
36
+ </IconButton>,
37
+ )
38
+ expect(screen.queryByRole('button')).toBeNull()
39
+ const link = screen.getByRole('link', { name: 'Open' })
40
+ expect(link.getAttribute('href')).toBe('/documents/12')
41
+ expect(link.className).toContain('rounded-lg')
42
+ await user.hover(link)
43
+ expect((await screen.findByRole('tooltip')).textContent).toBe('Open')
44
+ })
45
+
46
+ it('href while it cannot be used: the button, since a link cannot be disabled', () => {
47
+ render(
48
+ <IconButton href="/documents/12" aria-label="Open" disabledReason="Read only">
49
+ {icon}
50
+ </IconButton>,
51
+ )
52
+ expect(screen.queryByRole('link')).toBeNull()
53
+ expect(screen.getByRole('button', { name: 'Open' }).getAttribute('aria-disabled')).toBe('true')
54
+ })
55
+
31
56
  it('shows the tooltip on hover, with its shortcut', async () => {
32
57
  const user = userEvent.setup()
33
58
  render(
@@ -37,6 +37,14 @@ export interface IconButtonProps extends ComponentPropsWithRef<'button'> {
37
37
  /** Why the action cannot succeed right now. Disables the button, keeps it
38
38
  * reachable by keyboard, and shows the reason as the tooltip. */
39
39
  disabledReason?: string
40
+ /**
41
+ * A link that looks like an icon button: an anchor, so the address stays
42
+ * real for a modified click or a new tab, as `Link` keeps it. A router app
43
+ * passes `onClick`, prevents the default and navigates. A link cannot be
44
+ * disabled, so while it would be — `disabled`, `disabledReason`, a busy
45
+ * Form — it is the button (UIG-14, Katerina, 18 September).
46
+ */
47
+ href?: string
40
48
  /** The icon: 16px, stroke 1.5. */
41
49
  children: ReactNode
42
50
  }
@@ -53,56 +61,63 @@ export function IconButton({
53
61
  tooltipShortcut,
54
62
  tooltipPlacement,
55
63
  type = 'button',
64
+ href,
56
65
  ...props
57
66
  }: IconButtonProps) {
58
67
  // Inside a busy Form: switched off, and looking it (formBusy.ts).
59
68
  const formBusy = useFormBusy()
60
- const button = (
61
- <BaseButton
62
- type={type}
63
- disabled={disabled || !!disabledReason || formBusy}
64
- focusableWhenDisabled={!!disabledReason}
65
- className={(state) =>
66
- cn(
67
- // `shrink-0` stops a flex parent squashing the button; `self-center`
68
- // stops one stretching it. Two different failures, and both have
69
- // happened here: a row with no `items-*` drew this 24 wide and 228
70
- // tall in Peek's TopicMoreMenu story (Katerina, 2026-09-11). A button
71
- // is the size of its icon and its padding, whatever box it lands in.
72
- //
73
- // This is the one of the two that needs it: an IconButton states no
74
- // height, so its cross size is `auto` and a stretching parent takes
75
- // it. Measured in the same 260px row on 2026-09-12: 24px with this
76
- // class, 226px without. `Button` states `h-8`/`h-6`, so it cannot be
77
- // stretched and carries no `self-center` see the note there.
78
- 'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
79
- !state.disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
80
- !state.disabled && (variant === 'muted' || variant === 'resolve') && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
81
- !state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
82
- !state.disabled && variant === 'current' && 'hover:bg-bg-hover',
83
- state.disabled && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
84
- state.disabled && (variant === 'muted' || variant === 'resolve') && 'text-text-disabled',
85
- state.disabled && variant === 'outlined' && 'border border-border-default text-text-disabled',
86
- // Always, disabled too: the colour of where it sits, and Resolve's green.
87
- variant === 'current' && 'text-current',
88
- variant === 'resolve' && 'signal:hover:bg-success-muted signal:hover:text-success-default',
89
- pressed && 'bg-bg-active text-text-primary',
90
- glow && 'signal:shadow-glow-accent',
91
- // `pointer-events-none` only where the button is truly out of reach:
92
- // with a `disabledReason` the button IS the tooltip's trigger, and a
93
- // trigger the pointer cannot land on never opens one. Base UI already
94
- // swallows the click.
95
- state.disabled && !disabledReason && 'pointer-events-none',
96
- state.disabled && 'cursor-not-allowed',
97
- className,
98
- )
99
- }
100
- aria-pressed={pressed}
101
- {...props}
102
- >
103
- {children}
104
- </BaseButton>
105
- )
69
+ const unusable = disabled || !!disabledReason || formBusy
70
+ const look = (off: boolean) =>
71
+ cn(
72
+ // `shrink-0` stops a flex parent squashing the button; `self-center`
73
+ // stops one stretching it. Two different failures, and both have
74
+ // happened here: a row with no `items-*` drew this 24 wide and 228
75
+ // tall in Peek's TopicMoreMenu story (Katerina, 2026-09-11). A button
76
+ // is the size of its icon and its padding, whatever box it lands in.
77
+ //
78
+ // This is the one of the two that needs it: an IconButton states no
79
+ // height, so its cross size is `auto` and a stretching parent takes
80
+ // it. Measured in the same 260px row on 2026-09-12: 24px with this
81
+ // class, 226px without. `Button` states `h-8`/`h-6`, so it cannot be
82
+ // stretched and carries no `self-center` see the note there.
83
+ 'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 self-center cursor-pointer',
84
+ !off && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
85
+ !off && (variant === 'muted' || variant === 'resolve') && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
86
+ !off && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
87
+ !off && variant === 'current' && 'hover:bg-bg-hover',
88
+ off && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
89
+ off && (variant === 'muted' || variant === 'resolve') && 'text-text-disabled',
90
+ off && variant === 'outlined' && 'border border-border-default text-text-disabled',
91
+ // Always, disabled too: the colour of where it sits, and Resolve's green.
92
+ variant === 'current' && 'text-current',
93
+ variant === 'resolve' && 'signal:hover:bg-success-muted signal:hover:text-success-default',
94
+ pressed && 'bg-bg-active text-text-primary',
95
+ glow && 'signal:shadow-glow-accent',
96
+ // `pointer-events-none` only where the button is truly out of reach:
97
+ // with a `disabledReason` the button IS the tooltip's trigger, and a
98
+ // trigger the pointer cannot land on never opens one. Base UI already
99
+ // swallows the click.
100
+ off && !disabledReason && 'pointer-events-none',
101
+ off && 'cursor-not-allowed',
102
+ className,
103
+ )
104
+ const button =
105
+ href && !unusable ? (
106
+ <a href={href} className={look(false)} {...(props as unknown as ComponentPropsWithRef<'a'>)}>
107
+ {children}
108
+ </a>
109
+ ) : (
110
+ <BaseButton
111
+ type={type}
112
+ disabled={unusable}
113
+ focusableWhenDisabled={!!disabledReason}
114
+ className={(state) => look(state.disabled)}
115
+ aria-pressed={pressed}
116
+ {...props}
117
+ >
118
+ {children}
119
+ </BaseButton>
120
+ )
106
121
 
107
122
  const label = disabledReason ?? tooltip
108
123
  if (label) {
@@ -62,10 +62,19 @@ import { IdentityMenu } from '@estiva-app/ui'
62
62
  row's full height, and the panel hung from the bottom of *that* — measured
63
63
  360px below the face, with a scrollbar it should not have had.
64
64
 
65
+ ## What it owns
66
+
67
+ It does these by itself. Write none of them around it.
68
+
69
+ | It does | So you never write | Refused when written by hand |
70
+ |---|---|---|
71
+ | Everything a Menu owns: it floats, stays attached, closes on a press outside or on Escape, and walks its rows with the arrow keys | a menu of your own around the face | |
72
+ | Gives focus back to the face when it closes | focus code | |
73
+
65
74
  ## Keys
66
75
 
67
76
  Since stage 4 the panel is a Base UI `Menu`, and **the arrow keys walk the
68
- actions only** (Katerina, D22): they step over the identity block, the
77
+ actions only**: they step over the identity block, the
69
78
  workspace line and the notes, because those are not things you can do.
70
79
 
71
80
  | Input | What happens |
@@ -51,6 +51,17 @@ import { InlineChip } from '@estiva-app/ui'
51
51
  - A rich-text editor that renders chips from strings cannot use a component.
52
52
  `inlineChipClassName(tone)` gives it the same classes.
53
53
 
54
+ - `INLINE_CHIP_CLASSES` and `INLINE_CHIP_TONE_CLASSES` give the same look where
55
+ only classes can go — inside an editor.
56
+
57
+ ## What it owns
58
+
59
+ It does these by itself. Write none of them around it.
60
+
61
+ | It does | So you never write | Refused when written by hand |
62
+ |---|---|---|
63
+ | With `href`, keeps a real address, so a modified click or a new tab still works | a `<span>` with a click handler | |
64
+
54
65
  ## Props
55
66
 
56
67
  <Controls of={InlineChipStories.Neutral} />
package/src/Kbd.mdx CHANGED
@@ -41,8 +41,8 @@ Geist Mono owns `←` and `→` but draws them 10px wide, so a chip holding one
41
41
  comes out wider than the chips beside it. Measure before adding a symbol to
42
42
  this list; the font's coverage is not uniform.
43
43
 
44
- And a trigger is the character you would actually type. Peek's resolution
45
- block fires on `->`, so its row says `->` — there is no `→` key to press.
44
+ And a trigger is the character you would actually type. A shortcut that fires
45
+ on `->` shows `->` — there is no `→` key to press.
46
46
 
47
47
  ## When not
48
48
 
@@ -72,6 +72,10 @@ It renders what it is given and formats nothing. A modifier is called Cmd on
72
72
  Apple platforms and Ctrl elsewhere, and only the caller knows which it is
73
73
  looking at — so decide there and pass the finished label.
74
74
 
75
+ ## What it owns
76
+
77
+ Nothing. It only draws. Clicks and keys are yours.
78
+
75
79
  ## Props
76
80
 
77
81
  <Controls of={KbdStories.Default} />
package/src/Link.mdx CHANGED
@@ -12,6 +12,8 @@ the app decides how it navigates.
12
12
 
13
13
  ## When
14
14
 
15
+ - **It takes the size and colour of the text around it**: put it inside that
16
+ text.
15
17
  - **`text`** — a link written inside text: the info colour, always
16
18
  underlined, dimming on hover.
17
19
  - **`quiet`** — a title or a time that is also a link. It takes the colour and
@@ -28,6 +30,7 @@ the app decides how it navigates.
28
30
  - An action — something happens, nothing is navigated to → **Button**.
29
31
  - A row of a sidebar → **NavItem**. A tile of a rail → **RailItem**.
30
32
  - The trail back up → **Breadcrumb**.
33
+ - A link that looks like an icon button → **IconButton** with `href`.
31
34
  - A word standing for a person or a thing, drawn in a small box → **InlineChip**
32
35
  with `href`.
33
36
 
@@ -58,7 +61,16 @@ import { Link } from '@estiva-app/ui'
58
61
  Its size and colour still come from the text around it.
59
62
  - `quiet`, `underlined` and `plain` take their size and colour from where they
60
63
  sit, so put those on your own element around the link, not on the link —
61
- the lint refuses a look passed in `className` (UIG-9).
64
+ the lint refuses a look passed in `className`.
65
+
66
+ ## What it owns
67
+
68
+ It does these by itself. Write none of them around it.
69
+
70
+ | It does | So you never write | Refused when written by hand |
71
+ |---|---|---|
72
+ | Says what it is to assistive technology: a link | `role="link"` | ✓ |
73
+ | Is reachable with Tab | a `tabIndex` | ✓ |
62
74
 
63
75
  ## Props
64
76
 
@@ -0,0 +1,87 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ListColumnStories from './ListColumn.stories'
3
+
4
+ <Meta of={ListColumnStories} />
5
+
6
+ # ListColumn
7
+
8
+ The list column of a page: 290px wide with a line on its right, its name in a
9
+ `ContainerHeader`, and the list under it, which scrolls.
10
+
11
+ <Canvas of={ListColumnStories.Default} />
12
+
13
+ ## When
14
+
15
+ - **Its size is its own**: 290px wide and the full height of where it sits.
16
+ Nothing around it sets its width, and nothing squeezes it.
17
+ - A page that lists things beside the one that is open: the list on the left,
18
+ the chosen thing to its right.
19
+ - `spacing="rows"` (the default, 2px between rows) for a list of one kind of
20
+ row. `spacing="sections"` (4px) for groups with labels and dividers of their
21
+ own.
22
+ - `above` for a row that stays put while the list scrolls: a field that adds
23
+ to the list.
24
+ - `collapsed` closes it with the rail: it narrows to nothing and fades, in
25
+ 300ms. What opens and closes the rail is yours.
26
+
27
+ <Canvas of={ListColumnStories.Sections} />
28
+
29
+ <Canvas of={ListColumnStories.WithARowAbove} />
30
+
31
+ ## When not
32
+
33
+ - The app's own places, as the frame's column → **Sidebar**, in AppShell's
34
+ solid frame.
35
+ - A short list inside a panel or a card → your rows in a **ScrollArea**.
36
+ - A bar over something that is not a list → **ContainerHeader** on its own.
37
+
38
+ ## How
39
+
40
+ ```tsx
41
+ import { ListColumn, Toolbar, ToolbarButton } from '@estiva-app/ui'
42
+
43
+ <ListColumn
44
+ title="Items"
45
+ actions={
46
+ <Toolbar aria-label="Item list actions" surface={false}>
47
+ <ToolbarButton tooltip="New item" aria-label="New item" onClick={create}>
48
+ <IconEdit size={16} stroke={1.5} />
49
+ </ToolbarButton>
50
+ </Toolbar>
51
+ }
52
+ collapsed={railClosed}
53
+ >
54
+ {items.map((item) => (
55
+ <ItemRow key={item.id} item={item} />
56
+ ))}
57
+ </ListColumn>
58
+ ```
59
+
60
+ - It sits beside the page's main area, inside AppShell's card: put both in a
61
+ flex row, the main area `flex-1`.
62
+ - The numbers: 290px wide, a 1px line on the right. The header is
63
+ ContainerHeader's: 48px, the title 20px in. The list starts 16px under the
64
+ header and ends 12px above the bottom, and its rows sit 12px in from each
65
+ side.
66
+ - The rows are yours. It scrolls them, so a list that grows with its data is
67
+ never cut off at the fold, even when today's data fits.
68
+ - **Planned:** dragging its edge to resize it, with `SplitLayout` in
69
+ `@estiva-app/ui/layout` (migration stage 8). The 380px column beside a
70
+ conversation gets the same treatment then.
71
+
72
+ <Canvas of={ListColumnStories.ManyRows} />
73
+
74
+ ## What it owns
75
+
76
+ It does these by itself. Write none of them around it.
77
+
78
+ | It does | So you never write | Refused when written by hand |
79
+ |---|---|---|
80
+ | Draws the column: 290px, a line on its right, and a ContainerHeader on top | a width, a border and a header row of your own | |
81
+ | Scrolls its rows in a ScrollArea, 16px under the header, 12px in from each side | `overflow-auto`, or padding of your own around the rows | |
82
+ | Closes with the rail when `collapsed`: narrows to nothing and fades | a width transition of your own | |
83
+ | Keeps its 290px beside a main area that grows | `shrink-0` | |
84
+
85
+ ## Props
86
+
87
+ <Controls of={ListColumnStories.Default} />
@@ -0,0 +1,136 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSortDescending, IconSquareRounded } from '@tabler/icons-react'
3
+ import { useState } from 'react'
4
+ import { Button } from './Button'
5
+ import { Divider } from './Divider'
6
+ import { Form } from './Form'
7
+ import { ListColumn } from './ListColumn'
8
+ import { NavItem } from './NavItem'
9
+ import { SectionLabel } from './SectionLabel'
10
+ import { SkeletonList } from './Skeleton'
11
+ import { TextInput } from './TextInput'
12
+ import { Toolbar, ToolbarButton } from './Toolbar'
13
+
14
+ /**
15
+ * The list column of a page: 290px, a line on its right, a header, and a list
16
+ * that scrolls. The rows are the app's own; these stories draw `NavItem`s as
17
+ * stand-ins.
18
+ */
19
+ const meta = {
20
+ title: 'Layout/ListColumn',
21
+ component: ListColumn,
22
+ parameters: { layout: 'fullscreen' },
23
+ args: { title: 'Items', children: null },
24
+ argTypes: { children: { control: false }, actions: { control: false }, above: { control: false } },
25
+ // The column beside a main area, inside a surface — where it sits in the frame's card.
26
+ decorators: [
27
+ (Story) => (
28
+ <div className="flex h-screen bg-bg-surface">
29
+ {Story()}
30
+ <div className="min-w-0 flex-1" />
31
+ </div>
32
+ ),
33
+ ],
34
+ } satisfies Meta<typeof ListColumn>
35
+
36
+ export default meta
37
+ type Story = StoryObj<typeof meta>
38
+
39
+ const icon = <IconSquareRounded size={16} stroke={1.5} />
40
+ const names = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight']
41
+ const rows = (n: number) =>
42
+ Array.from({ length: n }, (_, i) => <NavItem key={i} href="#" label={`Item ${names[i] ?? i + 1}`} icon={icon} active={i === 1} />)
43
+
44
+ const actions = (
45
+ <Toolbar aria-label="Item list actions" surface={false}>
46
+ <ToolbarButton tooltip="Sort by" aria-label="Sort by">
47
+ <IconSortDescending size={16} stroke={1.5} />
48
+ </ToolbarButton>
49
+ <ToolbarButton tooltip="New item" aria-label="New item">
50
+ {icon}
51
+ </ToolbarButton>
52
+ </Toolbar>
53
+ )
54
+
55
+ /** A list of one kind of row, 2px apart, with the column's actions in its header. */
56
+ export const Default: Story = {
57
+ args: { title: 'Items', actions },
58
+ render: (args) => <ListColumn {...args}>{rows(6)}</ListColumn>,
59
+ }
60
+
61
+ /** Groups with labels and a divider of their own: 4px between rows. */
62
+ export const Sections: Story = {
63
+ args: { title: 'Items', spacing: 'sections' },
64
+ render: (args) => (
65
+ <ListColumn {...args}>
66
+ <div className="flex h-8 shrink-0 items-center px-2">
67
+ <SectionLabel>Group one</SectionLabel>
68
+ </div>
69
+ {rows(3)}
70
+ <Divider className="my-2" />
71
+ <div className="flex h-8 shrink-0 items-center px-2">
72
+ <SectionLabel>Group two</SectionLabel>
73
+ </div>
74
+ {rows(4)}
75
+ </ListColumn>
76
+ ),
77
+ }
78
+
79
+ /** A row under the header that stays put while the list scrolls: a field that adds to the list. */
80
+ export const WithARowAbove: Story = {
81
+ parameters: { controls: { disable: true } },
82
+ render: function RowAbove() {
83
+ const [name, setName] = useState('')
84
+ return (
85
+ <ListColumn
86
+ title="Items"
87
+ above={
88
+ <Form onSubmit={() => setName('')} className="flex items-center gap-2 px-3 pt-3 pb-1">
89
+ <TextInput className="min-w-0 flex-1" value={name} onChange={(e) => setName(e.target.value)} placeholder="New item name" aria-label="New item name" />
90
+ <Button className="shrink-0" type="submit" disabled={!name.trim()}>
91
+ Create
92
+ </Button>
93
+ </Form>
94
+ }
95
+ >
96
+ {rows(5)}
97
+ </ListColumn>
98
+ )
99
+ },
100
+ }
101
+
102
+ /** More rows than fit: the list scrolls under a header that stays. */
103
+ export const ManyRows: Story = {
104
+ args: { title: 'Items', actions },
105
+ render: (args) => <ListColumn {...args}>{rows(80)}</ListColumn>,
106
+ }
107
+
108
+ /** While the list is on its way: a skeleton shaped like the rows. */
109
+ export const Loading: Story = {
110
+ args: { title: 'Items' },
111
+ render: (args) => (
112
+ <ListColumn {...args}>
113
+ <SkeletonList rows={8} />
114
+ </ListColumn>
115
+ ),
116
+ }
117
+
118
+ /** Closed with the rail: it narrows to nothing and fades. Press the button to watch it. */
119
+ export const Collapsing: Story = {
120
+ parameters: { controls: { disable: true } },
121
+ render: function Collapsing() {
122
+ const [collapsed, setCollapsed] = useState(false)
123
+ return (
124
+ <>
125
+ <ListColumn title="Items" collapsed={collapsed}>
126
+ {rows(6)}
127
+ </ListColumn>
128
+ <div className="p-4">
129
+ <Button variant="outlined" onClick={() => setCollapsed((c) => !c)}>
130
+ {collapsed ? 'Open the column' : 'Close the column'}
131
+ </Button>
132
+ </div>
133
+ </>
134
+ )
135
+ },
136
+ }
@@ -0,0 +1,50 @@
1
+ // @vitest-environment jsdom
2
+ /** What the ListColumn page claims, pinned. Sizes are measured in the browser; these pin the parts. */
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
+ import { cleanup, render, screen } from '@testing-library/react'
5
+ import { ListColumn } from './ListColumn'
6
+
7
+ afterEach(cleanup)
8
+
9
+ describe('ListColumn', () => {
10
+ it('is headed by its title', () => {
11
+ render(<ListColumn title="Items">{null}</ListColumn>)
12
+ expect(screen.getByText('Items')).toBeTruthy()
13
+ })
14
+
15
+ it('is 290px wide with a line on its right, and keeps its width', () => {
16
+ const { container } = render(<ListColumn title="Items">{null}</ListColumn>)
17
+ const classes = (container.firstElementChild as HTMLElement).className.split(' ')
18
+ expect(classes).toContain('w-[290px]')
19
+ expect(classes).toContain('border-r')
20
+ expect(classes).toContain('shrink-0')
21
+ })
22
+
23
+ it('collapsed: narrows to nothing, loses its line, and fades', () => {
24
+ const { container } = render(<ListColumn title="Items" collapsed>{null}</ListColumn>)
25
+ const classes = (container.firstElementChild as HTMLElement).className.split(' ')
26
+ expect(classes).toContain('w-0')
27
+ expect(classes).toContain('border-r-0')
28
+ expect(classes).toContain('opacity-0')
29
+ })
30
+
31
+ it('draws its rows in the list, and a row above the list outside it', () => {
32
+ render(
33
+ <ListColumn title="Items" above={<p>Above</p>}>
34
+ <p>Row</p>
35
+ </ListColumn>,
36
+ )
37
+ const list = screen.getByText('Row').parentElement as HTMLElement
38
+ expect(list.className.split(' ')).toEqual(expect.arrayContaining(['px-3', 'pt-4', 'pb-3', 'gap-0.5']))
39
+ expect(list.contains(screen.getByText('Above'))).toBe(false)
40
+ })
41
+
42
+ it('sections: 4px between rows', () => {
43
+ render(
44
+ <ListColumn title="Items" spacing="sections">
45
+ <p>Row</p>
46
+ </ListColumn>,
47
+ )
48
+ expect((screen.getByText('Row').parentElement as HTMLElement).className.split(' ')).toContain('gap-1')
49
+ })
50
+ })
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { ContainerHeader } from './ContainerHeader'
4
+ import { ScrollArea } from './ScrollArea'
5
+
6
+ /**
7
+ * The list column of a page: the column beside the page's main area that lists
8
+ * what it can open — 290px wide, a line on its right, its name in a
9
+ * `ContainerHeader`, and a list under it that scrolls.
10
+ *
11
+ * Moved in from Peek as it looks (UIG-14, N9), where five pages drew it by
12
+ * hand and each carried the same numbers: the frame's 290px and its line, and
13
+ * the list's steps — 16px under the header, 12px above the bottom, rows 12px
14
+ * in from each side. The numbers live here now, once.
15
+ *
16
+ * It scrolls its rows itself. A list column that did not was the first of the
17
+ * eight mistakes on the page that led to UIG-15: the frame's card clips, so a
18
+ * list that grew past the fold was simply cut off, with nothing to scroll.
19
+ *
20
+ * `collapsed` closes it with the rail, as Peek's did: it narrows to nothing
21
+ * and fades, in 300ms. What opens and closes the rail stays the app's.
22
+ */
23
+ export interface ListColumnProps {
24
+ /** The column's name, in its header. A node for a title that is more than words. */
25
+ title: ReactNode
26
+ /** A chevron after the title, as `ContainerHeader` draws it. */
27
+ chevron?: boolean
28
+ /** The column's own actions, at the header's right — a `Toolbar` of `ToolbarButton`s, or one `IconButton`. */
29
+ actions?: ReactNode
30
+ /** A row between the header and the list that stays put while the list scrolls — a field that adds to the list. */
31
+ above?: ReactNode
32
+ /**
33
+ * The room between rows. `rows` (2px) for a list of one kind of row;
34
+ * `sections` (4px) for groups with labels and dividers of their own.
35
+ */
36
+ spacing?: 'rows' | 'sections'
37
+ /** Closes the column with the rail: it narrows to nothing and fades. */
38
+ collapsed?: boolean
39
+ /** The rows. */
40
+ children: ReactNode
41
+ /** Placement only. */
42
+ className?: string
43
+ }
44
+
45
+ export function ListColumn({ title, chevron = false, actions, above, spacing = 'rows', collapsed = false, children, className }: ListColumnProps) {
46
+ return (
47
+ <div
48
+ className={cn(
49
+ // `shrink-0`: the column keeps its 290px beside a main area that grows.
50
+ 'flex shrink-0 flex-col overflow-hidden border-r border-border-subtle transition-[width,opacity] duration-300 ease-in-out',
51
+ collapsed ? 'w-0 border-r-0 opacity-0' : 'w-[290px] opacity-100',
52
+ className,
53
+ )}
54
+ data-collapsed={collapsed || undefined}
55
+ >
56
+ <ContainerHeader title={title} chevron={chevron} actions={actions} />
57
+ {above}
58
+ <ScrollArea className="flex-1" contentClassName={cn('flex flex-col px-3 pt-4 pb-3', spacing === 'rows' ? 'gap-0.5' : 'gap-1')}>
59
+ {children}
60
+ </ScrollArea>
61
+ </div>
62
+ )
63
+ }
package/src/Menu.mdx CHANGED
@@ -41,7 +41,7 @@ definition either way.
41
41
  - Picking one value for a form field → **Select**.
42
42
  - Rows that toggle and stay open → that is a listbox, not a menu; a Checkbox
43
43
  inside a `menuitem` is invalid. Build it as a list in a **DialogShell**.
44
- - A strip of icon actions → a toolbar of **IconButton**s.
44
+ - A strip of icon actions → **Toolbar**.
45
45
  - Who-you-are and where-you-are → **IdentityMenu**, already built on this.
46
46
 
47
47
  ## How
@@ -76,6 +76,23 @@ the trigger as "close" rather than as a press outside.
76
76
 
77
77
  <Canvas of={MenuStories.OnACard} />
78
78
 
79
+ - **Planned:** one part for the pop-up lists while typing in an editor (UIG-31).
80
+
81
+ ## What it owns
82
+
83
+ It does these by itself. Write none of them around it.
84
+
85
+ | It does | So you never write | Refused when written by hand |
86
+ |---|---|---|
87
+ | Floats on top of the page | a portal, or a `z-index` to win | ✓ |
88
+ | Stays attached to its anchor on scroll and resize | a scroll or resize listener, or placement arithmetic | ✓ |
89
+ | Closes on a press outside | a click listener on the page | ✓ |
90
+ | Takes its keys by itself: Enter and Space choose a row, Escape closes it | a key listener on the page | ✓ |
91
+ | Moves through its items with the arrow keys, and jumps to a row by its first letters | an arrow-key handler | ✓ |
92
+ | Says what it is to assistive technology: a menu, and each row a menu item | `role="menu"` or `role="menuitem"` | ✓ |
93
+ | Keeps its own open state, and closes when a row is chosen | `useState` for open | |
94
+ | Gives focus back to the trigger when it closes | focus code | |
95
+
79
96
  ## Keys
80
97
 
81
98
  | Input | What happens |