@estiva-app/ui 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/AttachmentCard.d.ts +48 -6
  2. package/dist/AttachmentCard.d.ts.map +1 -1
  3. package/dist/ErrorBoundary.d.ts +32 -0
  4. package/dist/ErrorBoundary.d.ts.map +1 -0
  5. package/dist/Lightbox.d.ts +30 -0
  6. package/dist/Lightbox.d.ts.map +1 -0
  7. package/dist/ListColumn.d.ts +9 -8
  8. package/dist/ListColumn.d.ts.map +1 -1
  9. package/dist/Menu.d.ts +7 -0
  10. package/dist/Menu.d.ts.map +1 -1
  11. package/dist/eslint/index.js +4 -4
  12. package/dist/eslint/index.js.map +2 -2
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +634 -456
  16. package/dist/index.js.map +4 -4
  17. package/package.json +1 -1
  18. package/registry.json +157 -20
  19. package/src/AttachmentCard.mdx +32 -8
  20. package/src/AttachmentCard.stories.tsx +54 -0
  21. package/src/AttachmentCard.test.tsx +115 -0
  22. package/src/AttachmentCard.tsx +212 -37
  23. package/src/CollapsibleSection.mdx +1 -1
  24. package/src/Divider.mdx +3 -3
  25. package/src/ErrorBoundary.mdx +66 -0
  26. package/src/ErrorBoundary.stories.tsx +64 -0
  27. package/src/ErrorBoundary.test.tsx +79 -0
  28. package/src/ErrorBoundary.tsx +54 -0
  29. package/src/Lightbox.mdx +71 -0
  30. package/src/Lightbox.stories.tsx +72 -0
  31. package/src/Lightbox.test.tsx +65 -0
  32. package/src/Lightbox.tsx +87 -0
  33. package/src/ListColumn.mdx +11 -3
  34. package/src/ListColumn.stories.tsx +34 -10
  35. package/src/ListColumn.test.tsx +20 -7
  36. package/src/ListColumn.tsx +34 -11
  37. package/src/Menu.mdx +5 -2
  38. package/src/Menu.stories.tsx +6 -7
  39. package/src/Menu.test.tsx +19 -1
  40. package/src/Menu.tsx +11 -1
  41. package/src/SectionHeader.mdx +5 -1
  42. package/src/SectionLabel.mdx +4 -2
  43. package/src/eslint/no-rebuilt-behaviour.ts +4 -4
  44. package/src/index.ts +3 -1
  45. package/src/registry/registry.test.ts +6 -6
  46. package/tailwind-preset.js +3 -0
  47. package/tokens.css +4 -0
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type FC } from 'react'
1
+ import { useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type FC } from 'react'
2
2
  import { Button as BaseButton } from '@base-ui/react/button'
3
3
  import {
4
4
  IconAlertCircle,
@@ -19,6 +19,7 @@ import {
19
19
  } from '@tabler/icons-react'
20
20
  import { Card } from './Card'
21
21
  import { IconButton } from './IconButton'
22
+ import { Lightbox } from './Lightbox'
22
23
  import { Link } from './Link'
23
24
  import { WithTooltip, type WithTooltipProps } from './Tooltip'
24
25
  import { cn } from './cn'
@@ -29,10 +30,17 @@ import { cn } from './cn'
29
30
  * Peek's two attachment components, class for class (UIG-27, Katerina's ruling
30
31
  * of 14 September that both apps draw one): `FileAttachmentCard`, the card on
31
32
  * something already posted, and `PendingAttachmentChip`, the card in a composer
32
- * waiting to go. What stays in each app is what only the app can do: fetch the
33
- * bytes with the reader's own permission, open an image full screen, save a
34
- * file. The card is told the result — a `src`, an `href`, a `state` — and hands
35
- * a click back through `onOpen`, `onDownload` and `onRemove`.
33
+ * waiting to go. The card is told the result — a `src`, an `href`, a `state` —
34
+ * and hands a click back through `onOpen`, `onDownload` and `onRemove`.
35
+ *
36
+ * **It now does the rest of the job too** (UIG-35, Katerina 20 September):
37
+ * given a `remoteSrc` and the app's `fetchImage`, it fetches a file the reader
38
+ * needs permission for and draws the wait and the refusal; it opens a picture
39
+ * full screen in the package's `Lightbox`; and with `download` it saves the
40
+ * file. Peek and Ship had each written those three around it, 174 and 181 lines
41
+ * that did the same things differently — Ship's pictures never opened at all.
42
+ * What stays an app's is the one thing only it knows: how to fetch with its own
43
+ * authorization.
36
44
  *
37
45
  * Two shapes, decided by the file: an **image** with a picture is a 180px
38
46
  * thumbnail you can open; anything else is a 240px **row** — a type tile, the
@@ -144,10 +152,45 @@ export interface AttachmentCardProps extends Omit<ComponentPropsWithRef<'div'>,
144
152
  href?: string
145
153
  /** The picture an image card draws. Without it an image is drawn as a row. */
146
154
  src?: string
147
- /** What a click on an image's picture does — the app's full-screen view. */
155
+ /**
156
+ * What the picture shows, where the sender said — Ship carries one on every
157
+ * `imeta`. It is what a screen reader reads and what the viewer is named
158
+ * with; `name` is still the file's name, under the thumbnail. Without it the
159
+ * name does both jobs.
160
+ */
161
+ alt?: string
162
+ /**
163
+ * A file the app has to *fetch* before it can be used — one on a relay, where
164
+ * reading it needs the reader's own authorization (UIG-35). Give `fetchImage`
165
+ * with it. For a picture the card waits, draws it, and says so if it cannot
166
+ * be read, so no app writes that state machine again; for a document the
167
+ * result is where the row opens, and until it lands the row is dimmed rather
168
+ * than pretending to open. Ignored when `src` is set.
169
+ */
170
+ remoteSrc?: string
171
+ /**
172
+ * The full-size picture, fetched only when someone opens it. A thread of
173
+ * screenshots would otherwise pull every original at full size to draw them
174
+ * at 112px. Until it arrives the viewer shows the thumbnail.
175
+ */
176
+ remoteFullSrc?: string
177
+ /** How this app fetches a picture it is allowed to read: a URL in, a URL the
178
+ * browser can draw out (an object URL, usually). */
179
+ fetchImage?: (url: string) => Promise<string>
180
+ /**
181
+ * What a click on an image's picture does. Without it the card **opens the
182
+ * picture itself**, full screen, in the package's `Lightbox` — which is what
183
+ * both apps wanted and one of them had written by hand.
184
+ */
148
185
  onOpen?: () => void
149
186
  /** Shows a download control on hover or focus, and calls this. */
150
187
  onDownload?: () => void
188
+ /**
189
+ * Shows the download control and **saves the file itself**, under `name`:
190
+ * the original where there is one (`remoteFullSrc`), otherwise what the card
191
+ * is drawing. Use `onDownload` instead when the app must do it another way.
192
+ */
193
+ download?: boolean
151
194
  /** Waiting to be sent: the composer's 200px row, with a remove control on its corner. */
152
195
  pending?: boolean
153
196
  /** Removes a pending card. */
@@ -163,14 +206,89 @@ export interface AttachmentCardProps extends Omit<ComponentPropsWithRef<'div'>,
163
206
  noteHint?: string
164
207
  }
165
208
 
209
+ /**
210
+ * A picture that has to be fetched before it can be drawn.
211
+ *
212
+ * The result remembers which URL it is *about*, so an answer about a file this
213
+ * card is no longer showing is ignored during render rather than erased in an
214
+ * effect afterwards — setting state synchronously in an effect is a second
215
+ * render every time the card is handed a different file. Peek and Ship had
216
+ * written this hook once each; it lives here now (UIG-35).
217
+ */
218
+ function useRemoteImage(url: string | undefined, fetchImage?: (url: string) => Promise<string>) {
219
+ const [result, setResult] = useState<{ for?: string; src?: string; failed?: boolean }>({})
220
+ // The fetcher is an app's function and is usually written inline, so it is
221
+ // read through a ref: a new identity on every render must not re-fetch.
222
+ const fetcher = useRef(fetchImage)
223
+ fetcher.current = fetchImage
224
+ useEffect(() => {
225
+ if (!url || !fetcher.current) return
226
+ let live = true
227
+ fetcher.current(url).then(
228
+ (src) => {
229
+ if (live) setResult({ for: url, src })
230
+ },
231
+ () => {
232
+ if (live) setResult({ for: url, failed: true })
233
+ },
234
+ )
235
+ return () => {
236
+ live = false
237
+ }
238
+ }, [url])
239
+ const current = result.for === url
240
+ return { src: current ? result.src : undefined, failed: current ? !!result.failed : false }
241
+ }
242
+
243
+ /** Save a file under its own name: its bytes' object URL, handed to a
244
+ * temporary `<a download>`. Falls back to opening it when the fetch is
245
+ * refused — a cross-origin file the browser will not hand over. */
246
+ async function saveFile(source: string, name: string): Promise<void> {
247
+ /*
248
+ Bytes already in hand — an object URL from the app's own fetch, or a data
249
+ URL — go straight to the anchor. Fetching them again would ask the browser
250
+ to re-read a blob it is already holding, and `fetch` on a `blob:` URL is
251
+ exactly what a jsdom test cannot do either.
252
+ */
253
+ if (source.startsWith('blob:') || source.startsWith('data:')) {
254
+ handToAnchor(source, name)
255
+ return
256
+ }
257
+ try {
258
+ const res = await fetch(source)
259
+ if (!res.ok) throw new Error(String(res.status))
260
+ const objectUrl = URL.createObjectURL(await res.blob())
261
+ handToAnchor(objectUrl, name)
262
+ URL.revokeObjectURL(objectUrl)
263
+ } catch {
264
+ // A file the browser will not hand over is opened instead, which is the
265
+ // only other thing a person can do with it.
266
+ window.open(source, '_blank', 'noopener')
267
+ }
268
+ }
269
+
270
+ function handToAnchor(url: string, name: string) {
271
+ const a = document.createElement('a')
272
+ a.href = url
273
+ a.download = name
274
+ document.body.appendChild(a)
275
+ a.click()
276
+ a.remove()
277
+ }
278
+
166
279
  export function AttachmentCard({
167
280
  name,
168
281
  size,
169
282
  contentType,
170
283
  href,
171
284
  src,
285
+ alt,
286
+ remoteSrc,
287
+ remoteFullSrc,
288
+ fetchImage,
172
289
  onOpen,
173
290
  onDownload,
291
+ download = false,
174
292
  pending = false,
175
293
  onRemove,
176
294
  state = 'ready',
@@ -182,6 +300,60 @@ export function AttachmentCard({
182
300
  const image = isImage(name, contentType)
183
301
  const sizeText = size === undefined ? '' : formatBytes(size)
184
302
 
303
+ /*
304
+ The picture, and the state that goes with fetching one. A `src` the app
305
+ already has needs none of this; `remoteSrc` is the case where reading the
306
+ file is a request, with a wait and a way to fail.
307
+ */
308
+ const remote = useRemoteImage(src ? undefined : remoteSrc, fetchImage)
309
+ const picture = image ? (src ?? remote.src) : undefined
310
+ const fetching = !src && !!remoteSrc && !!fetchImage
311
+ /*
312
+ The wait and the refusal are drawn for a **picture**. A document that has to
313
+ be fetched keeps the row it always had and simply has nowhere to open yet:
314
+ its name, its type and its size are already on screen and are what the
315
+ reader is looking at, where an image without its bytes is a blank card.
316
+ */
317
+ const drawnState: AttachmentCardState =
318
+ state !== 'ready' ? state : image && fetching ? (remote.failed ? 'unreadable' : picture ? 'ready' : 'loading') : state
319
+ // Where a document opens: what the app passed, or the bytes it fetched.
320
+ const address = href ?? (!image && fetching ? remote.src : undefined)
321
+
322
+ // Full screen. The original is fetched only once somebody opens it, and the
323
+ // thumbnail stands in until it arrives: a moment of a smaller picture beats a
324
+ // moment of an empty screen.
325
+ const [opened, setOpened] = useState(false)
326
+ const full = useRemoteImage(opened ? remoteFullSrc : undefined, fetchImage)
327
+ const openable = image && !!picture && !pending
328
+ const openPicture = onOpen ?? (openable ? () => setOpened(true) : undefined)
329
+ const viewer =
330
+ opened && picture ? <Lightbox src={full.src ?? picture} alt={alt ?? name} onClose={() => setOpened(false)} /> : null
331
+
332
+ /*
333
+ Saving. `download` is the card doing it, which is what both apps were doing
334
+ around it; `onDownload` stays for an app that must do it another way. The
335
+ original is saved where there is one — never the thumbnail on screen — and
336
+ it is fetched here rather than reusing the viewer's copy, so a file can be
337
+ saved without ever being opened.
338
+ */
339
+ const saveHere = download
340
+ ? () => {
341
+ const source = remoteFullSrc ?? remoteSrc
342
+ if (source && fetchImage) {
343
+ void fetchImage(source).then(
344
+ (resolved) => saveFile(resolved, name),
345
+ // Without this a refused read is an unhandled rejection in the page
346
+ // rather than a download that did not happen.
347
+ () => {},
348
+ )
349
+ return
350
+ }
351
+ const local = address ?? picture
352
+ if (local) void saveFile(local, name)
353
+ }
354
+ : undefined
355
+ const saving = onDownload ?? saveHere
356
+
185
357
  if (pending) {
186
358
  const failed = state === 'failed'
187
359
  const warning = state === 'warning'
@@ -236,7 +408,7 @@ export function AttachmentCard({
236
408
  }
237
409
 
238
410
  // Fades in with the card's hover or keyboard focus, and keeps its slot when hidden, so revealing it never shifts the row.
239
- const download = onDownload ? (
411
+ const downloadControl = saving ? (
240
412
  // @estiva-escape: Download shows when its card is pointed at or focused, AttachmentCard's own action; IconButton has no reveal on its card's hover (UIG-9, Katerina 17 September)
241
413
  <IconButton
242
414
  variant="muted"
@@ -245,14 +417,14 @@ export function AttachmentCard({
245
417
  className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity"
246
418
  onClick={(event) => {
247
419
  event.stopPropagation()
248
- onDownload()
420
+ saving()
249
421
  }}
250
422
  >
251
423
  <IconDownload size={16} stroke={1.5} />
252
424
  </IconButton>
253
425
  ) : null
254
426
 
255
- if (state === 'loading') {
427
+ if (drawnState === 'loading') {
256
428
  return (
257
429
  // `role="status"`: a plain box may not carry a name (axe, aria-prohibited-attr), and a
258
430
  // status says what it is doing — loading — to a reader that cannot see the pulse.
@@ -263,7 +435,7 @@ export function AttachmentCard({
263
435
  )
264
436
  }
265
437
 
266
- if (state === 'unreadable') {
438
+ if (drawnState === 'unreadable') {
267
439
  return (
268
440
  // @estiva-escape: a file that could not be read is faded as well as dashed, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
269
441
  <Card fill="inset" unreadable className={cn('flex items-center gap-2 w-[240px] p-1.5 opacity-70', className)} {...props}>
@@ -278,30 +450,33 @@ export function AttachmentCard({
278
450
  )
279
451
  }
280
452
 
281
- if (image && src) {
282
- const picture = <img src={src} alt={name} className="w-full h-28 object-cover" />
453
+ if (image && picture) {
454
+ const thumbnail = <img src={picture} alt={alt ?? name} className="w-full h-28 object-cover" />
283
455
  return (
284
- <Card fill="inset" hover="hairline" clip className={cn('group relative flex flex-col w-[180px]', className)} {...props}>
285
- {onOpen ? (
286
- <BaseButton
287
- type="button"
288
- className="block w-full"
289
- aria-label={`Preview ${name}`}
290
- onClick={(event) => {
291
- event.stopPropagation()
292
- onOpen()
293
- }}
294
- >
295
- {picture}
296
- </BaseButton>
297
- ) : (
298
- <div className="block w-full">{picture}</div>
299
- )}
300
- <div className="flex items-center gap-1 pl-2 pr-1 py-1 min-w-0">
301
- <Truncating text={name} className="flex-1 text-caption text-text-primary truncate" wrapperClassName="flex-1" />
302
- {download}
303
- </div>
304
- </Card>
456
+ <>
457
+ <Card fill="inset" hover="hairline" clip className={cn('group relative flex flex-col w-[180px]', className)} {...props}>
458
+ {openPicture ? (
459
+ <BaseButton
460
+ type="button"
461
+ className="block w-full"
462
+ aria-label={`Preview ${name}`}
463
+ onClick={(event) => {
464
+ event.stopPropagation()
465
+ openPicture()
466
+ }}
467
+ >
468
+ {thumbnail}
469
+ </BaseButton>
470
+ ) : (
471
+ <div className="block w-full">{thumbnail}</div>
472
+ )}
473
+ <div className="flex items-center gap-1 pl-2 pr-1 py-1 min-w-0">
474
+ <Truncating text={name} className="flex-1 text-caption text-text-primary truncate" wrapperClassName="flex-1" />
475
+ {downloadControl}
476
+ </div>
477
+ </Card>
478
+ {viewer}
479
+ </>
305
480
  )
306
481
  }
307
482
 
@@ -318,15 +493,15 @@ export function AttachmentCard({
318
493
  )
319
494
  return (
320
495
  // @estiva-escape: a file that cannot be opened is faded, AttachmentCard's own state; Card has no faded state (UIG-9, Katerina 17 September)
321
- <Card fill="inset" hover={href ? 'hairline' : 'none'} className={cn('group flex items-center gap-2 w-[240px] p-1.5 pr-1', !href && 'opacity-70', className)} {...props}>
322
- {href ? (
323
- <Link href={href} external variant="plain" className="flex items-center gap-2 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
496
+ <Card fill="inset" hover={address ? 'hairline' : 'none'} className={cn('group flex items-center gap-2 w-[240px] p-1.5 pr-1', !address && 'opacity-70', className)} {...props}>
497
+ {address ? (
498
+ <Link href={address} external variant="plain" className="flex items-center gap-2 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
324
499
  {body}
325
500
  </Link>
326
501
  ) : (
327
502
  <div className="flex items-center gap-2 min-w-0 flex-1">{body}</div>
328
503
  )}
329
- {download}
504
+ {downloadControl}
330
505
  </Card>
331
506
  )
332
507
  }
@@ -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
+ }