@estiva-app/ui 0.24.0 → 0.25.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 (85) hide show
  1. package/dist/ErrorBoundary.d.ts +32 -0
  2. package/dist/ErrorBoundary.d.ts.map +1 -0
  3. package/dist/ListColumn.d.ts +42 -0
  4. package/dist/ListColumn.d.ts.map +1 -0
  5. package/dist/Menu.d.ts +7 -0
  6. package/dist/Menu.d.ts.map +1 -1
  7. package/dist/Select.d.ts +1 -0
  8. package/dist/Select.d.ts.map +1 -1
  9. package/dist/Toolbar.d.ts +18 -0
  10. package/dist/Toolbar.d.ts.map +1 -1
  11. package/dist/index.d.ts +4 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +183 -108
  14. package/dist/index.js.map +4 -4
  15. package/package.json +1 -1
  16. package/registry.json +211 -4
  17. package/src/AppShell.mdx +8 -1
  18. package/src/AttachmentCard.mdx +3 -1
  19. package/src/Avatar.mdx +7 -2
  20. package/src/Banner.mdx +4 -5
  21. package/src/Breadcrumb.mdx +2 -0
  22. package/src/Button.mdx +3 -2
  23. package/src/Button.tsx +2 -2
  24. package/src/Card.mdx +5 -1
  25. package/src/Checkbox.mdx +1 -0
  26. package/src/ChipInput.mdx +1 -1
  27. package/src/CollapsibleSection.mdx +4 -4
  28. package/src/ConfirmDialog.mdx +2 -2
  29. package/src/ContainerHeader.mdx +12 -3
  30. package/src/DialogShell.mdx +1 -1
  31. package/src/Divider.mdx +5 -2
  32. package/src/EditableText.mdx +6 -1
  33. package/src/EmptyState.mdx +2 -2
  34. package/src/ErrorBoundary.mdx +66 -0
  35. package/src/ErrorBoundary.stories.tsx +64 -0
  36. package/src/ErrorBoundary.test.tsx +79 -0
  37. package/src/ErrorBoundary.tsx +54 -0
  38. package/src/Field.mdx +3 -4
  39. package/src/Form.mdx +1 -1
  40. package/src/IconButton.mdx +1 -1
  41. package/src/IdentityMenu.mdx +1 -1
  42. package/src/InlineChip.mdx +3 -0
  43. package/src/Kbd.mdx +2 -2
  44. package/src/Link.mdx +3 -1
  45. package/src/ListColumn.mdx +95 -0
  46. package/src/ListColumn.stories.tsx +160 -0
  47. package/src/ListColumn.test.tsx +63 -0
  48. package/src/ListColumn.tsx +86 -0
  49. package/src/Menu.mdx +7 -2
  50. package/src/Menu.stories.tsx +6 -7
  51. package/src/Menu.test.tsx +19 -1
  52. package/src/Menu.tsx +11 -1
  53. package/src/MenuItem.mdx +3 -0
  54. package/src/NavItem.mdx +4 -0
  55. package/src/Person.mdx +3 -0
  56. package/src/PersonTrigger.mdx +2 -1
  57. package/src/PersonTrigger.tsx +1 -1
  58. package/src/Popover.mdx +2 -0
  59. package/src/Rail.mdx +3 -0
  60. package/src/RailItem.mdx +4 -0
  61. package/src/RailItem.tsx +1 -1
  62. package/src/Reaction.mdx +1 -0
  63. package/src/Reaction.tsx +1 -1
  64. package/src/ScrollArea.mdx +3 -0
  65. package/src/SectionHeader.mdx +10 -2
  66. package/src/SectionHeader.tsx +1 -1
  67. package/src/SectionLabel.mdx +10 -4
  68. package/src/Select.mdx +3 -2
  69. package/src/Select.test.tsx +12 -0
  70. package/src/Select.tsx +4 -3
  71. package/src/Skeleton.mdx +5 -1
  72. package/src/Skeleton.tsx +1 -1
  73. package/src/TextInput.mdx +1 -0
  74. package/src/TextInput.tsx +1 -1
  75. package/src/Toolbar.mdx +9 -2
  76. package/src/Toolbar.stories.tsx +19 -2
  77. package/src/Toolbar.test.tsx +24 -1
  78. package/src/Toolbar.tsx +22 -0
  79. package/src/Tooltip.mdx +2 -1
  80. package/src/Tooltip.test.tsx +22 -0
  81. package/src/Tooltip.tsx +1 -1
  82. package/src/TopBar.mdx +8 -1
  83. package/src/heights.test.tsx +78 -0
  84. package/src/index.ts +4 -2
  85. package/src/registry/registry.test.ts +16 -8
@@ -0,0 +1,66 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ErrorBoundaryStories from './ErrorBoundary.stories'
3
+
4
+ <Meta of={ErrorBoundaryStories} />
5
+
6
+ # ErrorBoundary
7
+
8
+ Keeps a crash inside what it holds: that part shows "Something went wrong …
9
+ Your data is safe — try again." with a Try again button, and the rest of the
10
+ page keeps working.
11
+
12
+ <Canvas of={ErrorBoundaryStories.InAPanel} />
13
+
14
+ ## When
15
+
16
+ - Around each part of the page that can break on its own: a column, a panel,
17
+ a conversation — and once around the whole app.
18
+ - `label` says what it holds, in the message and the console line: "Something
19
+ went wrong in the {label}."
20
+ - `fallbackClassName` is the room the message fills where no flex column
21
+ holds it: the whole screen, for the app's own.
22
+ - `frame` draws the message inside a frame of yours, in the same place.
23
+ **ListColumn** holds one of its own this way, so a column that breaks keeps
24
+ its title.
25
+
26
+ <Canvas of={ErrorBoundaryStories.TryAgain} />
27
+
28
+ ## When not
29
+
30
+ - A read that failed, which the app knows about → say it where the content
31
+ would be, with **EmptyState**; this part is for what nobody expected.
32
+ - Something the person did that failed → **Toast**, or **FieldLine** under
33
+ the field.
34
+ - Around a single small control: a crash is caught by the part of the page it
35
+ belongs to.
36
+
37
+ ## How
38
+
39
+ ```tsx
40
+ import { ErrorBoundary } from '@estiva-app/ui'
41
+
42
+ <ErrorBoundary label="conversation">
43
+ <Conversation id={id} />
44
+ </ErrorBoundary>
45
+ ```
46
+
47
+ - The message is **EmptyState** with its one action, so it takes the room it
48
+ is given and centres itself in it.
49
+ - Try again draws the part again. If what broke is still broken, the message
50
+ comes back.
51
+ - A crash is written to the console: "{label} crashed:", the error, and where
52
+ it happened.
53
+
54
+ ## What it owns
55
+
56
+ It does these by itself. Write none of them around it.
57
+
58
+ | It does | So you never write | Refused when written by hand |
59
+ |---|---|---|
60
+ | Catches a crash in what it holds, so the rest of the page keeps working | an error boundary of your own | |
61
+ | Shows the message and a Try again that draws the part again | a fallback and a reset of your own | |
62
+ | Writes the crash to the console, with what it holds | `console.error` in a boundary of your own | |
63
+
64
+ ## Props
65
+
66
+ <Controls of={ErrorBoundaryStories.InAPanel} />
@@ -0,0 +1,64 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { Button } from './Button'
4
+ import { ErrorBoundary } from './ErrorBoundary'
5
+
6
+ /**
7
+ * A part of the page that crashes shows this in its place, and the rest of the
8
+ * page keeps working. Try again draws the part again.
9
+ */
10
+ const meta = {
11
+ title: 'Feedback/ErrorBoundary',
12
+ component: ErrorBoundary,
13
+ parameters: { layout: 'fullscreen' },
14
+ args: { label: 'panel', children: null },
15
+ argTypes: { children: { control: false }, frame: { control: false } },
16
+ } satisfies Meta<typeof ErrorBoundary>
17
+
18
+ export default meta
19
+ type Story = StoryObj<typeof meta>
20
+
21
+ function Broken(): never {
22
+ throw new Error('This part could not draw itself')
23
+ }
24
+
25
+ /** A panel that crashed, beside one that did not. */
26
+ export const InAPanel: Story = {
27
+ args: { label: 'panel' },
28
+ render: (args) => (
29
+ <div className="flex h-screen bg-bg-surface">
30
+ <div className="flex w-72 flex-col border-r border-border-subtle">
31
+ <ErrorBoundary {...args}>
32
+ <Broken />
33
+ </ErrorBoundary>
34
+ </div>
35
+ <div className="flex flex-1 items-center justify-center text-body-2 text-text-secondary">The rest of the page keeps working.</div>
36
+ </div>
37
+ ),
38
+ }
39
+
40
+ /** Try again draws the part again: here the part is mended first, so it comes back. */
41
+ export const TryAgain: Story = {
42
+ parameters: { controls: { disable: true } },
43
+ render: function Mend() {
44
+ const [mended, setMended] = useState(false)
45
+ function Part() {
46
+ if (!mended) throw new Error('This part could not draw itself')
47
+ return <p className="p-4 text-body-2 text-text-primary">The part is back.</p>
48
+ }
49
+ return (
50
+ <div className="flex h-screen flex-col bg-bg-surface">
51
+ <div className="p-4">
52
+ <Button variant="outlined" onClick={() => setMended(true)}>
53
+ Mend the part
54
+ </Button>
55
+ </div>
56
+ <div className="flex flex-1 flex-col border-t border-border-subtle">
57
+ <ErrorBoundary label="panel">
58
+ <Part />
59
+ </ErrorBoundary>
60
+ </div>
61
+ </div>
62
+ )
63
+ },
64
+ }
@@ -0,0 +1,79 @@
1
+ // @vitest-environment jsdom
2
+ /** What the ErrorBoundary page claims, pinned. Moved in from Peek with the part (UIG-14, D3). */
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
5
+ import { useState } from 'react'
6
+ import { ErrorBoundary } from './ErrorBoundary'
7
+
8
+ afterEach(cleanup)
9
+
10
+ function Bomb({ defused }: { defused: boolean }) {
11
+ if (!defused) throw new Error('boom')
12
+ return <div>recovered content</div>
13
+ }
14
+
15
+ /** Flips the crash off outside the boundary, so Try again shows a real recovery
16
+ * (a child still broken would only crash again). */
17
+ function Harness() {
18
+ const [defused, setDefused] = useState(false)
19
+ return (
20
+ <>
21
+ <button onClick={() => setDefused(true)}>defuse</button>
22
+ <ErrorBoundary label="test panel">
23
+ <Bomb defused={defused} />
24
+ </ErrorBoundary>
25
+ </>
26
+ )
27
+ }
28
+
29
+ describe('ErrorBoundary', () => {
30
+ beforeEach(() => {
31
+ // React logs the caught error; keep the output quiet.
32
+ vi.spyOn(console, 'error').mockImplementation(() => {})
33
+ })
34
+ afterEach(() => {
35
+ vi.restoreAllMocks()
36
+ })
37
+
38
+ it('shows the message in place of what crashed', () => {
39
+ render(<Harness />)
40
+ expect(screen.getByText(/Something went wrong in the test panel/)).toBeTruthy()
41
+ expect(screen.queryByText('recovered content')).toBeNull()
42
+ })
43
+
44
+ it('keeps the rest of the page working', () => {
45
+ render(<Harness />)
46
+ expect(screen.getByText('defuse')).toBeTruthy()
47
+ })
48
+
49
+ it('Try again draws it again', () => {
50
+ render(<Harness />)
51
+ fireEvent.click(screen.getByText('defuse'))
52
+ fireEvent.click(screen.getByText('Try again'))
53
+ expect(screen.getByText('recovered content')).toBeTruthy()
54
+ expect(screen.queryByText(/Something went wrong/)).toBeNull()
55
+ })
56
+
57
+ it('draws its children untouched when nothing throws', () => {
58
+ render(
59
+ <ErrorBoundary>
60
+ <div>healthy</div>
61
+ </ErrorBoundary>,
62
+ )
63
+ expect(screen.getByText('healthy')).toBeTruthy()
64
+ })
65
+
66
+ it('draws the message inside a frame, when given one', () => {
67
+ render(
68
+ <ErrorBoundary label="list" frame={(message) => <section aria-label="frame">{message}</section>}>
69
+ <Bomb defused={false} />
70
+ </ErrorBoundary>,
71
+ )
72
+ expect(screen.getByLabelText('frame').textContent).toMatch(/Something went wrong in the list/)
73
+ })
74
+
75
+ it('says what crashed in the console line', () => {
76
+ render(<Harness />)
77
+ expect(vi.mocked(console.error).mock.calls.some((call) => String(call[0]).startsWith('test panel crashed:'))).toBe(true)
78
+ })
79
+ })
@@ -0,0 +1,54 @@
1
+ import { Component, type ReactNode } from 'react'
2
+ import { IconAlertTriangle } from '@tabler/icons-react'
3
+ import { EmptyState } from './EmptyState'
4
+
5
+ /**
6
+ * Catches a part of the page that crashes, and shows "Something went wrong …
7
+ * Your data is safe — try again." with a Try again button in its place, so the
8
+ * rest of the app keeps working. Try again draws that part again.
9
+ *
10
+ * Moved in from Peek as it was (UIG-14, D3, Katerina 19 September), where one
11
+ * wrapped each panel and the whole app. `ListColumn` holds one of its own.
12
+ */
13
+ export interface ErrorBoundaryProps {
14
+ children: ReactNode
15
+ /** What it holds, said in the message and the console line: "Something went wrong in the {label}." */
16
+ label?: string
17
+ /** The room the message fills where no flex column holds it — the whole screen, for the app's own. */
18
+ fallbackClassName?: string
19
+ /** Draws the message inside a frame of yours, in the same place: `ListColumn` keeps its title this way. */
20
+ frame?: (message: ReactNode) => ReactNode
21
+ }
22
+
23
+ interface ErrorBoundaryState {
24
+ hasError: boolean
25
+ }
26
+
27
+ /** Keeps a crash inside what it holds: the rest of the page keeps working, and Try again draws it again. */
28
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
29
+ state: ErrorBoundaryState = { hasError: false }
30
+
31
+ static getDerivedStateFromError(): ErrorBoundaryState {
32
+ return { hasError: true }
33
+ }
34
+
35
+ componentDidCatch(error: unknown, info: { componentStack?: string | null }) {
36
+ console.error(`${this.props.label ?? 'part of the page'} crashed:`, error, info.componentStack ?? '')
37
+ }
38
+
39
+ render() {
40
+ if (!this.state.hasError) return this.props.children
41
+ // EmptyState with its one action: in a flex column it takes the room and centres itself, with no box.
42
+ const message = (
43
+ <EmptyState
44
+ icon={<IconAlertTriangle size={16} stroke={1.5} />}
45
+ message={`Something went wrong${this.props.label ? ` in the ${this.props.label}` : ''}. Your data is safe — try again.`}
46
+ action={{ label: 'Try again', onClick: () => this.setState({ hasError: false }) }}
47
+ />
48
+ )
49
+ // Around a column: the column is drawn again, with the message in it.
50
+ if (this.props.frame) return this.props.frame(message)
51
+ // The whole app's: no column around it, so it names its own room.
52
+ return this.props.fallbackClassName ? <div className={this.props.fallbackClassName}>{message}</div> : message
53
+ }
54
+ }
package/src/Field.mdx CHANGED
@@ -43,14 +43,13 @@ import { Field, TextInput } from '@estiva-app/ui'
43
43
  reversed at stage 3: the Field used to win and override it.
44
44
  - `helper` is the line under the control — the format, or what happens if
45
45
  it is left empty. `error` **replaces** it and marks the control invalid,
46
- so you no longer pass `aria-invalid` yourself. Both are announced;
47
- Ship and Peek wrote this line by hand six times between them, and none of
48
- those was.
46
+ so you no longer pass `aria-invalid` yourself. Both are announced; a line
47
+ written by hand beside the control is not.
49
48
  - With neither, the control stays a direct child of the field, so nothing
50
49
  that predates the two props moved a pixel.
51
50
  - **`required` marks the control as well as the label.** The asterisk is a
52
51
  picture of the requirement; `aria-required` on the control is the word for
53
- it, and the control had neither until 2026-09-08 — measured. Base UI's
52
+ it. Base UI's
54
53
  `Field` has no `required` of its own, so the Field puts it there. A control
55
54
  that says so already is left alone, and a `children` of several elements
56
55
  keeps the asterisk and owes its own.
package/src/Form.mdx CHANGED
@@ -70,7 +70,7 @@ It does these by itself. Write none of them around it.
70
70
 
71
71
  ## Keys
72
72
 
73
- The same in every form (Katerina, 16 September).
73
+ The same in every form.
74
74
 
75
75
  | Key | Does |
76
76
  |---|---|
@@ -48,7 +48,7 @@ import { IconPencil } from '@tabler/icons-react'
48
48
  - `glow` lights it in Signal — the send arrow while there is something to send.
49
49
  - **Placed from outside, never restyled.** `className` takes space, size,
50
50
  flex and grid, and position; a colour, a text size, a border, a corner or a
51
- shadow is refused by the lint (`estiva/no-restyled-part`, UIG-9).
51
+ shadow is refused by the lint (`estiva/no-restyled-part`).
52
52
  - `type` defaults to `"button"`; it is a native `<button>` on Base UI's
53
53
  Button, and every native prop passes through.
54
54
  - `disabledReason="Read only"` disables it, keeps it reachable by Tab,
@@ -74,7 +74,7 @@ It does these by itself. Write none of them around it.
74
74
  ## Keys
75
75
 
76
76
  Since stage 4 the panel is a Base UI `Menu`, and **the arrow keys walk the
77
- actions only** (Katerina, D22): they step over the identity block, the
77
+ actions only**: they step over the identity block, the
78
78
  workspace line and the notes, because those are not things you can do.
79
79
 
80
80
  | Input | What happens |
@@ -51,6 +51,9 @@ 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
+
54
57
  ## What it owns
55
58
 
56
59
  It does these by itself. Write none of them around it.
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
 
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
@@ -59,7 +61,7 @@ import { Link } from '@estiva-app/ui'
59
61
  Its size and colour still come from the text around it.
60
62
  - `quiet`, `underlined` and `plain` take their size and colour from where they
61
63
  sit, so put those on your own element around the link, not on the link —
62
- the lint refuses a look passed in `className` (UIG-9).
64
+ the lint refuses a look passed in `className`.
63
65
 
64
66
  ## What it owns
65
67
 
@@ -0,0 +1,95 @@
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
+ - 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
+ - `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
+ - **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
+
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
+ | 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 | |
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 | |
89
+ | Closes with the rail when `collapsed`: narrows to nothing and fades | a width transition of your own | |
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 | |
92
+
93
+ ## Props
94
+
95
+ <Controls of={ListColumnStories.Default} />
@@ -0,0 +1,160 @@
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 { CollapsibleSection } from './CollapsibleSection'
6
+ import { Divider } from './Divider'
7
+ import { Form } from './Form'
8
+ import { ListColumn } from './ListColumn'
9
+ import { NavItem } from './NavItem'
10
+ import { SectionHeader } from './SectionHeader'
11
+ import { SkeletonList } from './Skeleton'
12
+ import { TextInput } from './TextInput'
13
+ import { Toolbar, ToolbarButton } from './Toolbar'
14
+
15
+ /**
16
+ * The list column of a page: 290px, a line on its right, a header, and a list
17
+ * that scrolls. The rows are the app's own; these stories draw `NavItem`s as
18
+ * stand-ins.
19
+ */
20
+ const meta = {
21
+ title: 'Layout/ListColumn',
22
+ component: ListColumn,
23
+ parameters: { layout: 'fullscreen' },
24
+ args: { title: 'Items', children: null },
25
+ argTypes: { children: { control: false }, actions: { control: false }, above: { control: false } },
26
+ // The column beside a main area, inside a surface — where it sits in the frame's card.
27
+ decorators: [
28
+ (Story) => (
29
+ <div className="flex h-screen bg-bg-surface">
30
+ {Story()}
31
+ <div className="min-w-0 flex-1" />
32
+ </div>
33
+ ),
34
+ ],
35
+ } satisfies Meta<typeof ListColumn>
36
+
37
+ export default meta
38
+ type Story = StoryObj<typeof meta>
39
+
40
+ const icon = <IconSquareRounded size={16} stroke={1.5} />
41
+ const names = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight']
42
+ const rows = (n: number) =>
43
+ Array.from({ length: n }, (_, i) => <NavItem key={i} href="#" label={`Item ${names[i] ?? i + 1}`} icon={icon} active={i === 1} />)
44
+
45
+ const actions = (
46
+ <Toolbar aria-label="Item list actions" surface={false}>
47
+ <ToolbarButton tooltip="Sort by" aria-label="Sort by">
48
+ <IconSortDescending size={16} stroke={1.5} />
49
+ </ToolbarButton>
50
+ <ToolbarButton tooltip="New item" aria-label="New item">
51
+ {icon}
52
+ </ToolbarButton>
53
+ </Toolbar>
54
+ )
55
+
56
+ /** A list of one kind of row, 2px apart, with the column's actions in its header. */
57
+ export const Default: Story = {
58
+ args: { title: 'Items', actions },
59
+ render: (args) => <ListColumn {...args}>{rows(6)}</ListColumn>,
60
+ }
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
+ */
67
+ export const Sections: Story = {
68
+ args: { title: 'Items' },
69
+ render: (args) => (
70
+ <ListColumn {...args}>
71
+ <CollapsibleSection title="Group one" contentClassName="mt-1 gap-0.5">
72
+ {rows(3)}
73
+ </CollapsibleSection>
74
+ <Divider className="my-2" />
75
+ <SectionHeader
76
+ title="Group two"
77
+ hover="none"
78
+ actions={[{ icon: <IconSortDescending size={16} stroke={1.5} />, tooltip: 'Sort by', onClick: () => {} }]}
79
+ />
80
+ {rows(4)}
81
+ </ListColumn>
82
+ ),
83
+ }
84
+
85
+ /** A row under the header that stays put while the list scrolls: a field that adds to the list. */
86
+ export const WithARowAbove: Story = {
87
+ parameters: { controls: { disable: true } },
88
+ render: function RowAbove() {
89
+ const [name, setName] = useState('')
90
+ return (
91
+ <ListColumn
92
+ title="Items"
93
+ above={
94
+ <Form onSubmit={() => setName('')} className="flex items-center gap-2 px-3 pt-3 pb-1">
95
+ <TextInput className="min-w-0 flex-1" value={name} onChange={(e) => setName(e.target.value)} placeholder="New item name" aria-label="New item name" />
96
+ <Button className="shrink-0" type="submit" disabled={!name.trim()}>
97
+ Create
98
+ </Button>
99
+ </Form>
100
+ }
101
+ >
102
+ {rows(5)}
103
+ </ListColumn>
104
+ )
105
+ },
106
+ }
107
+
108
+ /** More rows than fit: the list scrolls under a header that stays. */
109
+ export const ManyRows: Story = {
110
+ args: { title: 'Items', actions },
111
+ render: (args) => <ListColumn {...args}>{rows(80)}</ListColumn>,
112
+ }
113
+
114
+ /** While the list is on its way: a skeleton shaped like the rows. */
115
+ export const Loading: Story = {
116
+ args: { title: 'Items' },
117
+ render: (args) => (
118
+ <ListColumn {...args}>
119
+ <SkeletonList rows={8} />
120
+ </ListColumn>
121
+ ),
122
+ }
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
+
142
+ /** Closed with the rail: it narrows to nothing and fades. Press the button to watch it. */
143
+ export const Collapsing: Story = {
144
+ parameters: { controls: { disable: true } },
145
+ render: function Collapsing() {
146
+ const [collapsed, setCollapsed] = useState(false)
147
+ return (
148
+ <>
149
+ <ListColumn title="Items" collapsed={collapsed}>
150
+ {rows(6)}
151
+ </ListColumn>
152
+ <div className="p-4">
153
+ <Button variant="outlined" onClick={() => setCollapsed((c) => !c)}>
154
+ {collapsed ? 'Open the column' : 'Close the column'}
155
+ </Button>
156
+ </div>
157
+ </>
158
+ )
159
+ },
160
+ }
@@ -0,0 +1,63 @@
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, vi } 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('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
+ </>,
54
+ )
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()
62
+ })
63
+ })