@estiva-app/ui 0.24.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AttachmentCard.d.ts +48 -6
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/ErrorBoundary.d.ts +32 -0
- package/dist/ErrorBoundary.d.ts.map +1 -0
- package/dist/Lightbox.d.ts +30 -0
- package/dist/Lightbox.d.ts.map +1 -0
- package/dist/ListColumn.d.ts +9 -8
- package/dist/ListColumn.d.ts.map +1 -1
- package/dist/Menu.d.ts +7 -0
- package/dist/Menu.d.ts.map +1 -1
- package/dist/eslint/index.js +4 -4
- package/dist/eslint/index.js.map +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +634 -456
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +157 -20
- package/src/AttachmentCard.mdx +32 -8
- package/src/AttachmentCard.stories.tsx +54 -0
- package/src/AttachmentCard.test.tsx +115 -0
- package/src/AttachmentCard.tsx +212 -37
- package/src/CollapsibleSection.mdx +1 -1
- package/src/Divider.mdx +3 -3
- package/src/ErrorBoundary.mdx +66 -0
- package/src/ErrorBoundary.stories.tsx +64 -0
- package/src/ErrorBoundary.test.tsx +79 -0
- package/src/ErrorBoundary.tsx +54 -0
- package/src/Lightbox.mdx +71 -0
- package/src/Lightbox.stories.tsx +72 -0
- package/src/Lightbox.test.tsx +65 -0
- package/src/Lightbox.tsx +87 -0
- package/src/ListColumn.mdx +11 -3
- package/src/ListColumn.stories.tsx +34 -10
- package/src/ListColumn.test.tsx +20 -7
- package/src/ListColumn.tsx +34 -11
- package/src/Menu.mdx +5 -2
- package/src/Menu.stories.tsx +6 -7
- package/src/Menu.test.tsx +19 -1
- package/src/Menu.tsx +11 -1
- package/src/SectionHeader.mdx +5 -1
- package/src/SectionLabel.mdx +4 -2
- package/src/eslint/no-rebuilt-behaviour.ts +4 -4
- package/src/index.ts +3 -1
- package/src/registry/registry.test.ts +6 -6
- package/tailwind-preset.js +3 -0
- package/tokens.css +4 -0
package/src/AttachmentCard.tsx
CHANGED
|
@@ -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.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
420
|
+
saving()
|
|
249
421
|
}}
|
|
250
422
|
>
|
|
251
423
|
<IconDownload size={16} stroke={1.5} />
|
|
252
424
|
</IconButton>
|
|
253
425
|
) : null
|
|
254
426
|
|
|
255
|
-
if (
|
|
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 (
|
|
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 &&
|
|
282
|
-
const
|
|
453
|
+
if (image && picture) {
|
|
454
|
+
const thumbnail = <img src={picture} alt={alt ?? name} className="w-full h-28 object-cover" />
|
|
283
455
|
return (
|
|
284
|
-
|
|
285
|
-
{
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
event
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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={
|
|
322
|
-
{
|
|
323
|
-
<Link href={
|
|
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
|
-
{
|
|
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
|
|
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
|
+
}
|