@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/dist/ErrorBoundary.d.ts +32 -0
- package/dist/ErrorBoundary.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/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +142 -94
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +66 -19
- 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/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/index.ts +2 -1
- package/src/registry/registry.test.ts +6 -6
package/package.json
CHANGED
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.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
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
|
|
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
|
|
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
|
+
}
|
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
|
+
}
|