@estiva-app/ui 0.24.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.24.1",
8
- "exports": 84,
9
- "typeExports": 94,
7
+ "packageVersion": "0.25.0",
8
+ "exports": 86,
9
+ "typeExports": 95,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -1680,6 +1680,49 @@
1680
1680
  "docPage": null,
1681
1681
  "app": null
1682
1682
  },
1683
+ {
1684
+ "name": "ErrorBoundary",
1685
+ "repo": "estiva-ui",
1686
+ "kind": "component",
1687
+ "importPath": "@estiva-app/ui",
1688
+ "sourceFile": "src/ErrorBoundary.tsx",
1689
+ "purpose": "Keeps a crash inside what it holds: that part shows \"Something went wrong … Your data is safe — try again.\" with a Try again button, and the rest of the page keeps working.",
1690
+ "purposeFrom": "page",
1691
+ "props": [
1692
+ {
1693
+ "name": "children",
1694
+ "takes": "anything",
1695
+ "required": true,
1696
+ "note": null
1697
+ },
1698
+ {
1699
+ "name": "label",
1700
+ "takes": "text",
1701
+ "required": false,
1702
+ "note": "What it holds, said in the message and the console line: \"Something went wrong in the {label}.\""
1703
+ },
1704
+ {
1705
+ "name": "fallbackClassName",
1706
+ "takes": "text",
1707
+ "required": false,
1708
+ "note": "The room the message fills where no flex column holds it — the whole screen, for the app's own."
1709
+ },
1710
+ {
1711
+ "name": "frame",
1712
+ "takes": "a handler",
1713
+ "required": false,
1714
+ "note": "Draws the message inside a frame of yours, in the same place: `ListColumn` keeps its title this way."
1715
+ }
1716
+ ],
1717
+ "variants": [],
1718
+ "ownsBehaviours": [],
1719
+ "status": "stable",
1720
+ "migrationStage": null,
1721
+ "docsId": "feedback-errorboundary--docs",
1722
+ "storyId": "feedback-errorboundary--in-a-panel",
1723
+ "docPage": "src/ErrorBoundary.mdx",
1724
+ "app": null
1725
+ },
1683
1726
  {
1684
1727
  "name": "Field",
1685
1728
  "repo": "estiva-ui",
@@ -2428,12 +2471,6 @@
2428
2471
  "required": false,
2429
2472
  "note": "A row between the header and the list that stays put while the list scrolls — a field that adds to the list."
2430
2473
  },
2431
- {
2432
- "name": "spacing",
2433
- "takes": "rows | sections",
2434
- "required": false,
2435
- "note": "The room between rows. `rows` (2px) for a list of one kind of row; `sections` (4px) for groups with labels and dividers of their own."
2436
- },
2437
2474
  {
2438
2475
  "name": "collapsed",
2439
2476
  "takes": "true/false",
@@ -2444,7 +2481,7 @@
2444
2481
  "name": "children",
2445
2482
  "takes": "anything",
2446
2483
  "required": true,
2447
- "note": "The rows."
2484
+ "note": "The rows, 2px apart. Groups are headed by a `CollapsibleSection` or a `SectionHeader`."
2448
2485
  },
2449
2486
  {
2450
2487
  "name": "className",
@@ -2453,15 +2490,7 @@
2453
2490
  "note": "Placement only."
2454
2491
  }
2455
2492
  ],
2456
- "variants": [
2457
- {
2458
- "prop": "spacing",
2459
- "values": [
2460
- "rows",
2461
- "sections"
2462
- ]
2463
- }
2464
- ],
2493
+ "variants": [],
2465
2494
  "ownsBehaviours": [],
2466
2495
  "status": "stable",
2467
2496
  "migrationStage": null,
@@ -2766,6 +2795,24 @@
2766
2795
  "docPage": null,
2767
2796
  "app": null
2768
2797
  },
2798
+ {
2799
+ "name": "MenuSeparator",
2800
+ "repo": "estiva-ui",
2801
+ "kind": "component",
2802
+ "importPath": "@estiva-app/ui",
2803
+ "sourceFile": "src/Menu.tsx",
2804
+ "purpose": "The line between two groups of a menu, with its own 4px above and below (UIG-14, Katerina 19 September).",
2805
+ "purposeFrom": "comment",
2806
+ "props": [],
2807
+ "variants": [],
2808
+ "ownsBehaviours": [],
2809
+ "status": "stable",
2810
+ "migrationStage": null,
2811
+ "docsId": "overlays-menu--docs",
2812
+ "storyId": "overlays-menu--items",
2813
+ "docPage": null,
2814
+ "app": null
2815
+ },
2769
2816
  {
2770
2817
  "name": "MenuSub",
2771
2818
  "repo": "estiva-ui",
@@ -31,7 +31,7 @@ composition lives:
31
31
  ## When not
32
32
 
33
33
  - A heading over rows that never fold → **SectionHeader** without
34
- `chevron`, or **SectionLabel** in a 32px row.
34
+ `chevron`. Never a row drawn by hand.
35
35
  - Several panels where opening one closes the others → not here; that is
36
36
  an accordion, and no app has asked for one.
37
37
  - Content that appears on demand from a control elsewhere → **Popover**.
package/src/Divider.mdx CHANGED
@@ -14,8 +14,6 @@ orientation for assistive tech.
14
14
 
15
15
  ## When
16
16
 
17
- - Between groups in a **Menu** (`className="my-1"` gives it its breathing
18
- room there).
19
17
  - Between sections of a panel, a rail or a card.
20
18
  - Vertical: between clusters in a toolbar or a dense row.
21
19
 
@@ -30,6 +28,8 @@ orientation for assistive tech.
30
28
 
31
29
  ## When not
32
30
 
31
+ - Between groups in a menu → **MenuSeparator**: this line with its own 4px
32
+ above and below.
33
33
  - Between every row of a list — spacing does that; a hairline per row is
34
34
  noise.
35
35
  - To title what follows → **SectionLabel** or **SectionHeader**; a heading
@@ -49,7 +49,7 @@ import { Divider } from '@estiva-app/ui'
49
49
  - **Inside a `Menu` or a `Popover` it has no inset**: the hairline runs the
50
50
  width of the rows. The panel tells it so; nothing is passed in.
51
51
 
52
- - The line is 1px; the room around it is yours (`my-1` in a Menu).
52
+ - The line is 1px; the room around it is yours.
53
53
 
54
54
  ## What it owns
55
55
 
@@ -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
+ }
@@ -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
+ }