@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.
- package/dist/AttachmentCard.d.ts +48 -6
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/ErrorBoundary.d.ts +32 -0
- package/dist/ErrorBoundary.d.ts.map +1 -0
- package/dist/Lightbox.d.ts +30 -0
- package/dist/Lightbox.d.ts.map +1 -0
- package/dist/ListColumn.d.ts +9 -8
- package/dist/ListColumn.d.ts.map +1 -1
- package/dist/Menu.d.ts +7 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/eslint/index.js +4 -4
- package/dist/eslint/index.js.map +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +634 -456
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +157 -20
- package/src/AttachmentCard.mdx +32 -8
- package/src/AttachmentCard.stories.tsx +54 -0
- package/src/AttachmentCard.test.tsx +115 -0
- package/src/AttachmentCard.tsx +212 -37
- package/src/CollapsibleSection.mdx +1 -1
- package/src/Divider.mdx +3 -3
- package/src/ErrorBoundary.mdx +66 -0
- package/src/ErrorBoundary.stories.tsx +64 -0
- package/src/ErrorBoundary.test.tsx +79 -0
- package/src/ErrorBoundary.tsx +54 -0
- package/src/Lightbox.mdx +71 -0
- package/src/Lightbox.stories.tsx +72 -0
- package/src/Lightbox.test.tsx +65 -0
- package/src/Lightbox.tsx +87 -0
- package/src/ListColumn.mdx +11 -3
- package/src/ListColumn.stories.tsx +34 -10
- package/src/ListColumn.test.tsx +20 -7
- package/src/ListColumn.tsx +34 -11
- package/src/Menu.mdx +5 -2
- package/src/Menu.stories.tsx +6 -7
- package/src/Menu.test.tsx +19 -1
- package/src/Menu.tsx +11 -1
- package/src/SectionHeader.mdx +5 -1
- package/src/SectionLabel.mdx +4 -2
- package/src/eslint/no-rebuilt-behaviour.ts +4 -4
- package/src/index.ts +3 -1
- package/src/registry/registry.test.ts +6 -6
- package/tailwind-preset.js +3 -0
- package/tokens.css +4 -0
package/src/Lightbox.mdx
ADDED
|
@@ -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
|
+
})
|
package/src/Lightbox.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/ListColumn.mdx
CHANGED
|
@@ -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
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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'
|
|
68
|
+
args: { title: 'Items' },
|
|
64
69
|
render: (args) => (
|
|
65
70
|
<ListColumn {...args}>
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
</
|
|
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
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
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 } },
|
package/src/ListColumn.test.tsx
CHANGED
|
@@ -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('
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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(
|
|
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
|
})
|
package/src/ListColumn.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
144
|
-
|
|
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
|
|
package/src/Menu.stories.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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>
|