@estiva-app/ui 0.24.1 → 0.26.0

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 (47) hide show
  1. package/dist/AttachmentCard.d.ts +48 -6
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/ErrorBoundary.d.ts +32 -0
  4. package/dist/ErrorBoundary.d.ts.map +1 -0
  5. package/dist/Lightbox.d.ts +30 -0
  6. package/dist/Lightbox.d.ts.map +1 -0
  7. package/dist/ListColumn.d.ts +9 -8
  8. package/dist/ListColumn.d.ts.map +1 -1
  9. package/dist/Menu.d.ts +7 -0
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/eslint/index.js +4 -4
  12. package/dist/eslint/index.js.map +2 -2
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +634 -456
  16. package/dist/index.js.map +4 -4
  17. package/package.json +1 -1
  18. package/registry.json +157 -20
  19. package/src/AttachmentCard.mdx +32 -8
  20. package/src/AttachmentCard.stories.tsx +54 -0
  21. package/src/AttachmentCard.test.tsx +115 -0
  22. package/src/AttachmentCard.tsx +212 -37
  23. package/src/CollapsibleSection.mdx +1 -1
  24. package/src/Divider.mdx +3 -3
  25. package/src/ErrorBoundary.mdx +66 -0
  26. package/src/ErrorBoundary.stories.tsx +64 -0
  27. package/src/ErrorBoundary.test.tsx +79 -0
  28. package/src/ErrorBoundary.tsx +54 -0
  29. package/src/Lightbox.mdx +71 -0
  30. package/src/Lightbox.stories.tsx +72 -0
  31. package/src/Lightbox.test.tsx +65 -0
  32. package/src/Lightbox.tsx +87 -0
  33. package/src/ListColumn.mdx +11 -3
  34. package/src/ListColumn.stories.tsx +34 -10
  35. package/src/ListColumn.test.tsx +20 -7
  36. package/src/ListColumn.tsx +34 -11
  37. package/src/Menu.mdx +5 -2
  38. package/src/Menu.stories.tsx +6 -7
  39. package/src/Menu.test.tsx +19 -1
  40. package/src/Menu.tsx +11 -1
  41. package/src/SectionHeader.mdx +5 -1
  42. package/src/SectionLabel.mdx +4 -2
  43. package/src/eslint/no-rebuilt-behaviour.ts +4 -4
  44. package/src/index.ts +3 -1
  45. package/src/registry/registry.test.ts +6 -6
  46. package/tailwind-preset.js +3 -0
  47. package/tokens.css +4 -0
@@ -0,0 +1,71 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LightboxStories from './Lightbox.stories'
3
+
4
+ <Meta of={LightboxStories} />
5
+
6
+ # Lightbox
7
+
8
+ A picture, full screen, on a scrim — what a thumbnail opens into. Built on
9
+ Base UI's `Dialog`, like DialogShell.
10
+
11
+ <Canvas of={LightboxStories.APicture} />
12
+
13
+ ## When
14
+
15
+ - Somebody presses a picture that is cropped or scaled down where it sits — a
16
+ screenshot on a message, a photo in a thread — and wants to see the whole of
17
+ it.
18
+ - **AttachmentCard opens one by itself**, so an image attachment needs nothing
19
+ here: this is for a picture that is not an attachment card.
20
+
21
+ ## When not
22
+
23
+ - Anything that is not a picture → **DialogShell**.
24
+ - A picture small enough to read where it is — don't; an overlay that shows the
25
+ same thing at the same size is a click that did nothing.
26
+ - A gallery of several pictures with next and previous. This shows one. Ask for
27
+ the part rather than building the arrows around it.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { Lightbox } from '@estiva-app/ui'
33
+
34
+ {opened && <Lightbox src={pictureUrl} alt={name} onClose={() => setOpened(false)} />}
35
+ ```
36
+
37
+ - It is mounted while it is open: there is no `open` prop, and a caller renders
38
+ it or does not — DialogShell's rule.
39
+ - **Swapping `src` swaps the picture**, with no flash of nothing. That is how a
40
+ thumbnail stands in while the original is still being fetched.
41
+ - The picture is contained: as large as the screen allows, never cropped, never
42
+ stretched.
43
+ - `alt` names the picture, and names the viewer itself to assistive technology.
44
+
45
+ ## What it owns
46
+
47
+ It does these by itself. Write none of them around it.
48
+
49
+ | It does | So you never write | Refused when written by hand |
50
+ |---|---|---|
51
+ | Floats on top of the page, over its own scrim | a portal, a fixed layer, a `z-index` to win | ✓ |
52
+ | Takes its keys by itself — Escape closes it | a `keydown` listener on the page | ✓ |
53
+ | Holds focus inside while open, and gives it back to whatever opened it | focus code, Tab compared by hand | ✓ |
54
+ | Stops the page behind it scrolling | `overflow` written onto the body | ✓ |
55
+ | Closes on a press on the scrim, and **not** on a press on the picture | a click handler that tests the target | |
56
+ | Carries its own close button, sized to be findable on a photograph | an IconButton on a corner | |
57
+
58
+ ## Keys
59
+
60
+ | Input | What happens |
61
+ |---|---|
62
+ | Escape | closes |
63
+ | scrim click | closes |
64
+ | picture click | nothing — clicking what you opened should not close it |
65
+ | close button | closes |
66
+ | Tab / Shift+Tab | stays inside the viewer |
67
+ | on close | focus returns to the picture that opened it |
68
+
69
+ ## Props
70
+
71
+ <Controls of={LightboxStories.APicture} />
@@ -0,0 +1,72 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { Button } from './Button'
4
+ import { Lightbox } from './Lightbox'
5
+
6
+ /** A drawn picture rather than a photograph, so the story needs no network. */
7
+ const svg = (w: number, h: number, body: string) =>
8
+ `data:image/svg+xml;utf8,${encodeURIComponent(
9
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#0f1720"/>${body}</svg>`,
10
+ )}`
11
+
12
+ const WIDE = svg(
13
+ 960,
14
+ 540,
15
+ `<circle cx="240" cy="180" r="104" fill="#56c8ff"/><rect x="392" y="300" width="440" height="140" rx="24" fill="#4ade8c"/>`,
16
+ )
17
+
18
+ const TALL = svg(
19
+ 540,
20
+ 960,
21
+ `<rect x="80" y="120" width="380" height="380" rx="32" fill="#b18cff"/><circle cx="270" cy="740" r="140" fill="#ffc94d"/>`,
22
+ )
23
+
24
+ const meta = {
25
+ title: 'Overlays/Lightbox',
26
+ component: Lightbox,
27
+ parameters: {
28
+ layout: 'fullscreen',
29
+ // Portals a full-screen overlay to document.body — render in an iframe on
30
+ // the Docs page so it does not cover the docs.
31
+ docs: { story: { inline: false, height: '520px' } },
32
+ },
33
+ args: {
34
+ src: WIDE,
35
+ alt: 'checkout-flow.png',
36
+ onClose: () => {},
37
+ },
38
+ } satisfies Meta<typeof Lightbox>
39
+
40
+ export default meta
41
+ type Story = StoryObj<typeof meta>
42
+
43
+ /**
44
+ * A wide picture, as large as the screen allows and never larger: it is
45
+ * contained, so nothing is cropped and nothing is stretched.
46
+ */
47
+ export const APicture: Story = {}
48
+
49
+ /** A tall one in the same viewer — the height is what runs out first. */
50
+ export const ATallPicture: Story = { args: { src: TALL, alt: 'signup-screen.png' } }
51
+
52
+ /**
53
+ * **Open it and close it**, which is the whole point of the part.
54
+ *
55
+ * Escape closes it, so does the ✕ and so does a press on the scrim — a press on
56
+ * the picture does not. Focus is trapped while it is open and returns to the
57
+ * button that opened it, which is what neither hand-built viewer did.
58
+ */
59
+ export const OpenAndClose: Story = {
60
+ parameters: { controls: { disable: true } },
61
+ render: (args) => {
62
+ const [open, setOpen] = useState(false)
63
+ return (
64
+ <div className="flex h-screen items-center justify-center">
65
+ <Button variant="primary" onClick={() => setOpen(true)}>
66
+ Open the picture
67
+ </Button>
68
+ {open && <Lightbox {...args} onClose={() => setOpen(false)} />}
69
+ </div>
70
+ )
71
+ },
72
+ }
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Lightbox page claims, pinned: it floats over the page on its own
4
+ * scrim, it names itself, Escape closes it, a press on the scrim closes it and
5
+ * a press on the picture does not, and the close button closes it.
6
+ *
7
+ * The behaviours the two hand-built viewers never had are Base UI's, so what
8
+ * is tested here is that this part is wired to them — not Base UI itself.
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { Lightbox } from './Lightbox'
14
+
15
+ afterEach(cleanup)
16
+
17
+ const PICTURE = 'data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E'
18
+
19
+ describe('Lightbox', () => {
20
+ it('floats over the page: it portals out of its parent, and carries the strong scrim', () => {
21
+ const { container } = render(<Lightbox src={PICTURE} alt="checkout-flow.png" onClose={() => {}} />)
22
+ // Nothing is left where it was written.
23
+ expect(container.firstElementChild).toBeNull()
24
+ const picture = screen.getByAltText('checkout-flow.png')
25
+ expect(document.body.contains(picture)).toBe(true)
26
+ expect(document.querySelector('.bg-scrim-strong')).toBeTruthy()
27
+ })
28
+
29
+ it('names itself with the picture, so a reader is told what opened', () => {
30
+ render(<Lightbox src={PICTURE} alt="signup-screen.png" onClose={() => {}} />)
31
+ expect(screen.getByRole('dialog', { name: 'signup-screen.png' })).toBeTruthy()
32
+ })
33
+
34
+ it('Escape closes it', async () => {
35
+ const onClose = vi.fn()
36
+ render(<Lightbox src={PICTURE} alt="a.png" onClose={onClose} />)
37
+ await userEvent.keyboard('{Escape}')
38
+ expect(onClose).toHaveBeenCalled()
39
+ })
40
+
41
+ it('the close button closes it', async () => {
42
+ const onClose = vi.fn()
43
+ render(<Lightbox src={PICTURE} alt="a.png" onClose={onClose} />)
44
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }))
45
+ expect(onClose).toHaveBeenCalled()
46
+ })
47
+
48
+ it('a press on the scrim closes it; a press on the picture does not', async () => {
49
+ const onClose = vi.fn()
50
+ render(<Lightbox src={PICTURE} alt="a.png" onClose={onClose} />)
51
+
52
+ await userEvent.click(screen.getByAltText('a.png'))
53
+ expect(onClose).not.toHaveBeenCalled()
54
+
55
+ await userEvent.click(screen.getByRole('dialog'))
56
+ expect(onClose).toHaveBeenCalledTimes(1)
57
+ })
58
+
59
+ it('swapping the picture swaps what is on screen — a thumbnail, then the original', () => {
60
+ const { rerender } = render(<Lightbox src={PICTURE} alt="a.png" onClose={() => {}} />)
61
+ expect(screen.getByAltText('a.png').getAttribute('src')).toBe(PICTURE)
62
+ rerender(<Lightbox src="blob:original" alt="a.png" onClose={() => {}} />)
63
+ expect(screen.getByAltText('a.png').getAttribute('src')).toBe('blob:original')
64
+ })
65
+ })
@@ -0,0 +1,87 @@
1
+ import { useRef } from 'react'
2
+ import { Dialog } from '@base-ui/react/dialog'
3
+ import { IconX } from '@tabler/icons-react'
4
+ import { cn } from './cn'
5
+
6
+ /**
7
+ * A picture, full screen, on a scrim — the thing a thumbnail opens into.
8
+ *
9
+ * Both apps had written this by hand and neither had got it right: Peek's
10
+ * `ImageLightbox` and the `FrameLightbox` before it were `createPortal` plus a
11
+ * fixed `<div>`, with no Escape, no focus trap and nothing to return focus to
12
+ * (Peek scan F5, P4; migration B22, Katerina 10 September). Ship never had one
13
+ * at all, so a screenshot in an issue could not be looked at.
14
+ *
15
+ * On Base UI's `Dialog`, like `DialogShell`: the portal, the backdrop, Escape,
16
+ * the outside press and the focus trap are its, and what is written here is the
17
+ * picture, the scrim and the close button.
18
+ *
19
+ * **The close button is this component's own**, not an `IconButton`: a 26px
20
+ * square with no fill is hard to find on top of a photograph, which is why Peek
21
+ * had written an escape note beside its own. A 32px round button on an elevated
22
+ * fill with a hairline reads on any picture.
23
+ */
24
+ export interface LightboxProps {
25
+ /** The picture to show. Swapping it — a thumbnail for the original, once it
26
+ * arrives — swaps what is on screen, with no flash of nothing. */
27
+ src: string
28
+ /** What the picture is. Names the viewer for assistive tech too. */
29
+ alt: string
30
+ onClose: () => void
31
+ /** Extra classes on the picture itself. */
32
+ className?: string
33
+ }
34
+
35
+ export function Lightbox({ src, alt, onClose, className }: LightboxProps) {
36
+ const popupRef = useRef<HTMLDivElement>(null)
37
+ return (
38
+ <Dialog.Root
39
+ open
40
+ onOpenChange={(open) => {
41
+ if (!open) onClose()
42
+ }}
43
+ >
44
+ <Dialog.Portal>
45
+ <Dialog.Backdrop className="fixed inset-0 z-40 bg-scrim-strong" />
46
+ <Dialog.Popup
47
+ ref={popupRef}
48
+ /*
49
+ Focus the layer, not the ✕ — DialogShell's rule, for the same
50
+ reason: Base UI's default is the first tabbable element, which here
51
+ would open every picture with a ring drawn on its close button. The
52
+ trap is armed either way, and Tab reaches the ✕ first.
53
+ */
54
+ initialFocus={popupRef}
55
+ aria-label={alt}
56
+ /*
57
+ The popup IS the layer: a picture viewer has no card, so the popup
58
+ fills the screen and centres what it holds. `outline-none` because
59
+ it is a programmatic focus target rather than something tabbed to —
60
+ the same reason DialogShell gives.
61
+
62
+ A press anywhere on the layer closes it, which is what a full-screen
63
+ picture teaches people to expect. Base UI's outside-press cannot do
64
+ that here: there is no outside.
65
+ */
66
+ className="fixed inset-0 z-50 flex items-center justify-center p-8 outline-none"
67
+ onClick={onClose}
68
+ >
69
+ <img
70
+ src={src}
71
+ alt={alt}
72
+ /* The press on the picture is not a press on the scrim: clicking
73
+ the thing you opened should not close it. */
74
+ onClick={(e) => e.stopPropagation()}
75
+ className={cn('max-w-full max-h-full object-contain rounded-lg', className)}
76
+ />
77
+ <Dialog.Close
78
+ aria-label="Close"
79
+ className="absolute top-4 right-4 size-8 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary"
80
+ >
81
+ <IconX size={16} stroke={1.5} />
82
+ </Dialog.Close>
83
+ </Dialog.Popup>
84
+ </Dialog.Portal>
85
+ </Dialog.Root>
86
+ )
87
+ }
@@ -16,9 +16,9 @@ The list column of a page: 290px wide with a line on its right, its name in a
16
16
  Nothing around it sets its width, and nothing squeezes it.
17
17
  - A page that lists things beside the one that is open: the list on the left,
18
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.
19
+ - Its rows sit 2px apart, one kind of row or groups alike. A group that folds
20
+ is a **CollapsibleSection**; a group that does not is a **SectionHeader**
21
+ over its rows. Never a heading row drawn by hand.
22
22
  - `above` for a row that stays put while the list scrolls: a field that adds
23
23
  to the list.
24
24
  - `collapsed` closes it with the rail: it narrows to nothing and fades, in
@@ -71,6 +71,13 @@ import { ListColumn, Toolbar, ToolbarButton } from '@estiva-app/ui'
71
71
 
72
72
  <Canvas of={ListColumnStories.ManyRows} />
73
73
 
74
+ - **A crash stays inside it.** If its rows, its actions or the row above
75
+ break, it keeps its title and shows "Something went wrong in the list
76
+ panel… try again" in the middle of the room the list had. The rest of the page keeps working.
77
+ It is **ErrorBoundary**'s, held by the column itself.
78
+
79
+ <Canvas of={ListColumnStories.WhenARowBreaks} />
80
+
74
81
  ## What it owns
75
82
 
76
83
  It does these by itself. Write none of them around it.
@@ -81,6 +88,7 @@ It does these by itself. Write none of them around it.
81
88
  | 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
89
  | Closes with the rail when `collapsed`: narrows to nothing and fades | a width transition of your own | |
83
90
  | Keeps its 290px beside a main area that grows | `shrink-0` | |
91
+ | Keeps a crash inside itself: its title stays, the message and Try again sit in the middle of the room the list had | an error boundary of your own around it | |
84
92
 
85
93
  ## Props
86
94
 
@@ -2,11 +2,12 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconSortDescending, IconSquareRounded } from '@tabler/icons-react'
3
3
  import { useState } from 'react'
4
4
  import { Button } from './Button'
5
+ import { CollapsibleSection } from './CollapsibleSection'
5
6
  import { Divider } from './Divider'
6
7
  import { Form } from './Form'
7
8
  import { ListColumn } from './ListColumn'
8
9
  import { NavItem } from './NavItem'
9
- import { SectionLabel } from './SectionLabel'
10
+ import { SectionHeader } from './SectionHeader'
10
11
  import { SkeletonList } from './Skeleton'
11
12
  import { TextInput } from './TextInput'
12
13
  import { Toolbar, ToolbarButton } from './Toolbar'
@@ -58,19 +59,24 @@ export const Default: Story = {
58
59
  render: (args) => <ListColumn {...args}>{rows(6)}</ListColumn>,
59
60
  }
60
61
 
61
- /** Groups with labels and a divider of their own: 4px between rows. */
62
+ /**
63
+ * Groups. Each is headed by a package part, never by a row drawn by hand: a
64
+ * group that folds is a `CollapsibleSection`; a group that does not fold is a
65
+ * `SectionHeader` over its rows.
66
+ */
62
67
  export const Sections: Story = {
63
- args: { title: 'Items', spacing: 'sections' },
68
+ args: { title: 'Items' },
64
69
  render: (args) => (
65
70
  <ListColumn {...args}>
66
- <div className="flex h-8 shrink-0 items-center px-2">
67
- <SectionLabel>Group one</SectionLabel>
68
- </div>
69
- {rows(3)}
71
+ <CollapsibleSection title="Group one" contentClassName="mt-1 gap-0.5">
72
+ {rows(3)}
73
+ </CollapsibleSection>
70
74
  <Divider className="my-2" />
71
- <div className="flex h-8 shrink-0 items-center px-2">
72
- <SectionLabel>Group two</SectionLabel>
73
- </div>
75
+ <SectionHeader
76
+ title="Group two"
77
+ hover="none"
78
+ actions={[{ icon: <IconSortDescending size={16} stroke={1.5} />, tooltip: 'Sort by', onClick: () => {} }]}
79
+ />
74
80
  {rows(4)}
75
81
  </ListColumn>
76
82
  ),
@@ -115,6 +121,24 @@ export const Loading: Story = {
115
121
  ),
116
122
  }
117
123
 
124
+ function Broken(): never {
125
+ throw new Error('A row could not draw itself')
126
+ }
127
+
128
+ /**
129
+ * When a row breaks: the column keeps its title and says so in the middle of
130
+ * the room the list had, with Try again. The rest of the page keeps working.
131
+ */
132
+ export const WhenARowBreaks: Story = {
133
+ args: { title: 'Items', actions },
134
+ render: (args) => (
135
+ <ListColumn {...args}>
136
+ {rows(2)}
137
+ <Broken />
138
+ </ListColumn>
139
+ ),
140
+ }
141
+
118
142
  /** Closed with the rail: it narrows to nothing and fades. Press the button to watch it. */
119
143
  export const Collapsing: Story = {
120
144
  parameters: { controls: { disable: true } },
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
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'
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
4
  import { cleanup, render, screen } from '@testing-library/react'
5
5
  import { ListColumn } from './ListColumn'
6
6
 
@@ -39,12 +39,25 @@ describe('ListColumn', () => {
39
39
  expect(list.contains(screen.getByText('Above'))).toBe(false)
40
40
  })
41
41
 
42
- it('sections: 4px between rows', () => {
43
- render(
44
- <ListColumn title="Items" spacing="sections">
45
- <p>Row</p>
46
- </ListColumn>,
42
+ it('a row that breaks: the column keeps its title, the message is where the list was, the rest stays', () => {
43
+ const quiet = vi.spyOn(console, 'error').mockImplementation(() => {})
44
+ function Broken(): never {
45
+ throw new Error('boom')
46
+ }
47
+ const { container } = render(
48
+ <>
49
+ <ListColumn title="Items" actions={<button>Sort</button>}>
50
+ <Broken />
51
+ </ListColumn>
52
+ <p>The rest of the page</p>
53
+ </>,
47
54
  )
48
- expect((screen.getByText('Row').parentElement as HTMLElement).className.split(' ')).toContain('gap-1')
55
+ expect(screen.getByText('Items')).toBeTruthy()
56
+ expect(screen.getByText(/Something went wrong in the list panel/)).toBeTruthy()
57
+ // Only the name comes back: the actions may be what threw.
58
+ expect(screen.queryByText('Sort')).toBeNull()
59
+ expect(screen.getByText('The rest of the page')).toBeTruthy()
60
+ expect((container.firstElementChild as HTMLElement).className.split(' ')).toContain('w-[290px]')
61
+ quiet.mockRestore()
49
62
  })
50
63
  })
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { ContainerHeader } from './ContainerHeader'
4
+ import { ErrorBoundary } from './ErrorBoundary'
4
5
  import { ScrollArea } from './ScrollArea'
5
6
 
6
7
  /**
@@ -11,12 +12,18 @@ import { ScrollArea } from './ScrollArea'
11
12
  * Moved in from Peek as it looks (UIG-14, N9), where five pages drew it by
12
13
  * hand and each carried the same numbers: the frame's 290px and its line, and
13
14
  * 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
+ * in from each side and 2px apart. The numbers live here now, once. One
16
+ * spacing for every list (Katerina, D5): a list of groups heads each with a
17
+ * `CollapsibleSection` or a `SectionHeader`.
15
18
  *
16
19
  * It scrolls its rows itself. A list column that did not was the first of the
17
20
  * eight mistakes on the page that led to UIG-15: the frame's card clips, so a
18
21
  * list that grew past the fold was simply cut off, with nothing to scroll.
19
22
  *
23
+ * It keeps a crash inside itself (D1, D3): if its rows, its actions or the row
24
+ * above break, the column keeps its title and shows `ErrorBoundary`'s message
25
+ * in the middle of the room the list had, and the rest of the page keeps working.
26
+ *
20
27
  * `collapsed` closes it with the rail, as Peek's did: it narrows to nothing
21
28
  * and fades, in 300ms. What opens and closes the rail stays the app's.
22
29
  */
@@ -29,20 +36,15 @@ export interface ListColumnProps {
29
36
  actions?: ReactNode
30
37
  /** A row between the header and the list that stays put while the list scrolls — a field that adds to the list. */
31
38
  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
39
  /** Closes the column with the rail: it narrows to nothing and fades. */
38
40
  collapsed?: boolean
39
- /** The rows. */
41
+ /** The rows, 2px apart. Groups are headed by a `CollapsibleSection` or a `SectionHeader`. */
40
42
  children: ReactNode
41
43
  /** Placement only. */
42
44
  className?: string
43
45
  }
44
46
 
45
- export function ListColumn({ title, chevron = false, actions, above, spacing = 'rows', collapsed = false, children, className }: ListColumnProps) {
47
+ function Column({ title, chevron = false, actions, above, collapsed = false, children, className, broken = false }: ListColumnProps & { broken?: boolean }) {
46
48
  return (
47
49
  <div
48
50
  className={cn(
@@ -55,9 +57,30 @@ export function ListColumn({ title, chevron = false, actions, above, spacing = '
55
57
  >
56
58
  <ContainerHeader title={title} chevron={chevron} actions={actions} />
57
59
  {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>
60
+ {broken ? (
61
+ // The message takes the room the list had and centres itself in it (Katerina, 19 September).
62
+ <div className="flex min-h-0 flex-1 flex-col px-3">{children}</div>
63
+ ) : (
64
+ <ScrollArea className="flex-1" contentClassName="flex flex-col gap-0.5 px-3 pt-4 pb-3">
65
+ {children}
66
+ </ScrollArea>
67
+ )}
61
68
  </div>
62
69
  )
63
70
  }
71
+
72
+ export function ListColumn(props: ListColumnProps) {
73
+ return (
74
+ <ErrorBoundary
75
+ label="list panel"
76
+ frame={(message) => (
77
+ // Only the name comes back with the message: the actions and the row above may be what threw.
78
+ <Column title={props.title} chevron={props.chevron} collapsed={props.collapsed} className={props.className} broken>
79
+ {message}
80
+ </Column>
81
+ )}
82
+ >
83
+ <Column {...props} />
84
+ </ErrorBoundary>
85
+ )
86
+ }
package/src/Menu.mdx CHANGED
@@ -140,8 +140,11 @@ Four surfaces used to claim menu items of nothing.
140
140
 
141
141
  A menu whose rows are `px-3` (tall picker rows) aligns its headings by passing
142
142
  `className="px-3"` to `MenuSection` — the heading row is `px-2` by default,
143
- matching the default rows. A `Divider` inside a menu needs nothing: the panel
144
- already runs it the width of the rows it separates.
143
+ matching the default rows.
144
+
145
+ **Between groups, a `MenuSeparator`**: the line, the width of the rows it
146
+ separates, with its own 4px above and below. Not a `Divider`, whose room is
147
+ its caller's.
145
148
 
146
149
  ## Props
147
150
 
@@ -1,9 +1,8 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
2
  import { IconCopy, IconDots, IconPencil, IconSquareRounded, IconTrash } from '@tabler/icons-react'
3
3
  import { Button } from './Button'
4
- import { Divider } from './Divider'
5
4
  import { IconButton } from './IconButton'
6
- import { Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub } from './Menu'
5
+ import { Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSeparator, MenuSub } from './Menu'
7
6
  import { SectionLabel } from './SectionLabel'
8
7
 
9
8
  /**
@@ -41,7 +40,7 @@ export const Items: Story = {
41
40
  <MenuPanel className="min-w-[180px]">
42
41
  <MenuItem label="Rename" leading={icon(IconPencil)} onClick={() => {}} />
43
42
  <MenuItem label="Duplicate" leading={icon(IconCopy)} shortcut="Ctrl+D" onClick={() => {}} />
44
- <Divider className="my-1" />
43
+ <MenuSeparator />
45
44
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
46
45
  </MenuPanel>
47
46
  ),
@@ -57,7 +56,7 @@ export const Sections: Story = {
57
56
  <MenuItem label="Item one" selected onClick={() => {}} />
58
57
  <MenuItem label="Item two" onClick={() => {}} />
59
58
  </MenuSection>
60
- <Divider className="my-1" />
59
+ <MenuSeparator />
61
60
  <MenuSection label="Another section">
62
61
  <MenuItem label="Option one" onClick={() => {}} />
63
62
  <MenuItem label="Option two" onClick={() => {}} />
@@ -75,7 +74,7 @@ export const WithARow: Story = {
75
74
  <span className="min-w-0 flex-1 truncate text-body-2 text-text-primary">Item one</span>
76
75
  <SectionLabel tone="secondary">Label</SectionLabel>
77
76
  </MenuRow>
78
- <Divider className="my-1" />
77
+ <MenuSeparator />
79
78
  <MenuItem label="An action" onClick={() => {}} />
80
79
  </MenuPanel>
81
80
  ),
@@ -99,7 +98,7 @@ export const FromATrigger: Story = {
99
98
  <MenuItem label="Item two" onClick={() => {}} />
100
99
  <MenuItem label="Item three" onClick={() => {}} />
101
100
  </MenuSub>
102
- <Divider className="my-1" />
101
+ <MenuSeparator />
103
102
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
104
103
  </Menu>
105
104
  ),
@@ -149,7 +148,7 @@ export const OnACard: Story = {
149
148
  <MenuItem label="Item two" onClick={() => {}} />
150
149
  </MenuSub>
151
150
  </MenuSection>
152
- <Divider className="my-1" />
151
+ <MenuSeparator />
153
152
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
154
153
  </Menu>
155
154
  </div>
package/src/Menu.test.tsx CHANGED
@@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
11
11
  import { cleanup, render, screen, waitFor } from '@testing-library/react'
12
12
  import userEvent from '@testing-library/user-event'
13
13
  import { Button } from './Button'
14
- import { Menu, MenuItem, MenuPanel, MenuSection, MenuSub } from './Menu'
14
+ import { Menu, MenuItem, MenuPanel, MenuSection, MenuSeparator, MenuSub } from './Menu'
15
15
 
16
16
  afterEach(cleanup)
17
17
 
@@ -269,6 +269,24 @@ describe('Menu', () => {
269
269
  * plugin already owns the keyboard — so `MenuItem` and `MenuSection` have to
270
270
  * work with no `Menu` above them.
271
271
  */
272
+ describe('MenuSeparator', () => {
273
+ // UIG-14: the line between groups brings its own 4px above and below; a plain
274
+ // Divider left that to each caller, and Peek's main menus had none.
275
+ it('is a separator with its own room, the width of the rows', async () => {
276
+ render(
277
+ <Menu open trigger={<Button>More</Button>}>
278
+ <MenuItem label="One" />
279
+ <MenuSeparator />
280
+ <MenuItem label="Two" />
281
+ </Menu>,
282
+ )
283
+ const line = await screen.findByRole('separator')
284
+ const classes = (line.getAttribute('class') ?? '').split(' ')
285
+ expect(classes).toContain('my-1')
286
+ expect(classes).toContain('mx-0')
287
+ })
288
+ })
289
+
272
290
  describe('rows on a bare MenuPanel', () => {
273
291
  /**
274
292
  * It is a button, and it does NOT claim to be a menu item. ARIA requires a
package/src/Menu.tsx CHANGED
@@ -2,7 +2,7 @@ import { createContext, useContext, type ComponentPropsWithRef, type ReactElemen
2
2
  import { IconChevronRight } from '@tabler/icons-react'
3
3
  import { Menu as BaseMenu } from '@base-ui/react/menu'
4
4
  import { cn } from './cn'
5
- import { DividerInPanel } from './Divider'
5
+ import { Divider, DividerInPanel } from './Divider'
6
6
  import { triggerDisabled } from './triggerDisabled'
7
7
  import { ScrollArea } from './ScrollArea'
8
8
  import { Kbd } from './Kbd'
@@ -590,6 +590,16 @@ export function MenuSection({ label, children, className }: { label: string; chi
590
590
  )
591
591
  }
592
592
 
593
+ /**
594
+ * The line between two groups of a menu, with its own 4px above and below
595
+ * (UIG-14, Katerina 19 September). A plain `Divider` left that room to each
596
+ * caller, and Peek's menus drew it three ways — none, 4px, 4px and inset — so
597
+ * the lines in its main menus touched the rows.
598
+ */
599
+ export function MenuSeparator() {
600
+ return <Divider className="my-1" />
601
+ }
602
+
593
603
  /** A non-interactive row inside a menu, at the item's own geometry. */
594
604
  export function MenuRow({ children, className }: { children: ReactNode; className?: string }) {
595
605
  return <div className={cn('flex items-center gap-2 px-2 py-1.5', className)}>{children}</div>