@estiva-app/ui 0.25.0 → 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.
@@ -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
  }
@@ -0,0 +1,71 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as LightboxStories from './Lightbox.stories'
3
+
4
+ <Meta of={LightboxStories} />
5
+
6
+ # Lightbox
7
+
8
+ A picture, full screen, on a scrim — what a thumbnail opens into. Built on
9
+ Base UI's `Dialog`, like DialogShell.
10
+
11
+ <Canvas of={LightboxStories.APicture} />
12
+
13
+ ## When
14
+
15
+ - Somebody presses a picture that is cropped or scaled down where it sits — a
16
+ screenshot on a message, a photo in a thread — and wants to see the whole of
17
+ it.
18
+ - **AttachmentCard opens one by itself**, so an image attachment needs nothing
19
+ here: this is for a picture that is not an attachment card.
20
+
21
+ ## When not
22
+
23
+ - Anything that is not a picture → **DialogShell**.
24
+ - A picture small enough to read where it is — don't; an overlay that shows the
25
+ same thing at the same size is a click that did nothing.
26
+ - A gallery of several pictures with next and previous. This shows one. Ask for
27
+ the part rather than building the arrows around it.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { Lightbox } from '@estiva-app/ui'
33
+
34
+ {opened && <Lightbox src={pictureUrl} alt={name} onClose={() => setOpened(false)} />}
35
+ ```
36
+
37
+ - It is mounted while it is open: there is no `open` prop, and a caller renders
38
+ it or does not — DialogShell's rule.
39
+ - **Swapping `src` swaps the picture**, with no flash of nothing. That is how a
40
+ thumbnail stands in while the original is still being fetched.
41
+ - The picture is contained: as large as the screen allows, never cropped, never
42
+ stretched.
43
+ - `alt` names the picture, and names the viewer itself to assistive technology.
44
+
45
+ ## What it owns
46
+
47
+ It does these by itself. Write none of them around it.
48
+
49
+ | It does | So you never write | Refused when written by hand |
50
+ |---|---|---|
51
+ | Floats on top of the page, over its own scrim | a portal, a fixed layer, a `z-index` to win | ✓ |
52
+ | Takes its keys by itself — Escape closes it | a `keydown` listener on the page | ✓ |
53
+ | Holds focus inside while open, and gives it back to whatever opened it | focus code, Tab compared by hand | ✓ |
54
+ | Stops the page behind it scrolling | `overflow` written onto the body | ✓ |
55
+ | Closes on a press on the scrim, and **not** on a press on the picture | a click handler that tests the target | |
56
+ | Carries its own close button, sized to be findable on a photograph | an IconButton on a corner | |
57
+
58
+ ## Keys
59
+
60
+ | Input | What happens |
61
+ |---|---|
62
+ | Escape | closes |
63
+ | scrim click | closes |
64
+ | picture click | nothing — clicking what you opened should not close it |
65
+ | close button | closes |
66
+ | Tab / Shift+Tab | stays inside the viewer |
67
+ | on close | focus returns to the picture that opened it |
68
+
69
+ ## Props
70
+
71
+ <Controls of={LightboxStories.APicture} />
@@ -0,0 +1,72 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { Button } from './Button'
4
+ import { Lightbox } from './Lightbox'
5
+
6
+ /** A drawn picture rather than a photograph, so the story needs no network. */
7
+ const svg = (w: number, h: number, body: string) =>
8
+ `data:image/svg+xml;utf8,${encodeURIComponent(
9
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#0f1720"/>${body}</svg>`,
10
+ )}`
11
+
12
+ const WIDE = svg(
13
+ 960,
14
+ 540,
15
+ `<circle cx="240" cy="180" r="104" fill="#56c8ff"/><rect x="392" y="300" width="440" height="140" rx="24" fill="#4ade8c"/>`,
16
+ )
17
+
18
+ const TALL = svg(
19
+ 540,
20
+ 960,
21
+ `<rect x="80" y="120" width="380" height="380" rx="32" fill="#b18cff"/><circle cx="270" cy="740" r="140" fill="#ffc94d"/>`,
22
+ )
23
+
24
+ const meta = {
25
+ title: 'Overlays/Lightbox',
26
+ component: Lightbox,
27
+ parameters: {
28
+ layout: 'fullscreen',
29
+ // Portals a full-screen overlay to document.body — render in an iframe on
30
+ // the Docs page so it does not cover the docs.
31
+ docs: { story: { inline: false, height: '520px' } },
32
+ },
33
+ args: {
34
+ src: WIDE,
35
+ alt: 'checkout-flow.png',
36
+ onClose: () => {},
37
+ },
38
+ } satisfies Meta<typeof Lightbox>
39
+
40
+ export default meta
41
+ type Story = StoryObj<typeof meta>
42
+
43
+ /**
44
+ * A wide picture, as large as the screen allows and never larger: it is
45
+ * contained, so nothing is cropped and nothing is stretched.
46
+ */
47
+ export const APicture: Story = {}
48
+
49
+ /** A tall one in the same viewer — the height is what runs out first. */
50
+ export const ATallPicture: Story = { args: { src: TALL, alt: 'signup-screen.png' } }
51
+
52
+ /**
53
+ * **Open it and close it**, which is the whole point of the part.
54
+ *
55
+ * Escape closes it, so does the ✕ and so does a press on the scrim — a press on
56
+ * the picture does not. Focus is trapped while it is open and returns to the
57
+ * button that opened it, which is what neither hand-built viewer did.
58
+ */
59
+ export const OpenAndClose: Story = {
60
+ parameters: { controls: { disable: true } },
61
+ render: (args) => {
62
+ const [open, setOpen] = useState(false)
63
+ return (
64
+ <div className="flex h-screen items-center justify-center">
65
+ <Button variant="primary" onClick={() => setOpen(true)}>
66
+ Open the picture
67
+ </Button>
68
+ {open && <Lightbox {...args} onClose={() => setOpen(false)} />}
69
+ </div>
70
+ )
71
+ },
72
+ }
@@ -0,0 +1,65 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Lightbox page claims, pinned: it floats over the page on its own
4
+ * scrim, it names itself, Escape closes it, a press on the scrim closes it and
5
+ * a press on the picture does not, and the close button closes it.
6
+ *
7
+ * The behaviours the two hand-built viewers never had are Base UI's, so what
8
+ * is tested here is that this part is wired to them — not Base UI itself.
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { Lightbox } from './Lightbox'
14
+
15
+ afterEach(cleanup)
16
+
17
+ const PICTURE = 'data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%2F%3E'
18
+
19
+ describe('Lightbox', () => {
20
+ it('floats over the page: it portals out of its parent, and carries the strong scrim', () => {
21
+ const { container } = render(<Lightbox src={PICTURE} alt="checkout-flow.png" onClose={() => {}} />)
22
+ // Nothing is left where it was written.
23
+ expect(container.firstElementChild).toBeNull()
24
+ const picture = screen.getByAltText('checkout-flow.png')
25
+ expect(document.body.contains(picture)).toBe(true)
26
+ expect(document.querySelector('.bg-scrim-strong')).toBeTruthy()
27
+ })
28
+
29
+ it('names itself with the picture, so a reader is told what opened', () => {
30
+ render(<Lightbox src={PICTURE} alt="signup-screen.png" onClose={() => {}} />)
31
+ expect(screen.getByRole('dialog', { name: 'signup-screen.png' })).toBeTruthy()
32
+ })
33
+
34
+ it('Escape closes it', async () => {
35
+ const onClose = vi.fn()
36
+ render(<Lightbox src={PICTURE} alt="a.png" onClose={onClose} />)
37
+ await userEvent.keyboard('{Escape}')
38
+ expect(onClose).toHaveBeenCalled()
39
+ })
40
+
41
+ it('the close button closes it', async () => {
42
+ const onClose = vi.fn()
43
+ render(<Lightbox src={PICTURE} alt="a.png" onClose={onClose} />)
44
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }))
45
+ expect(onClose).toHaveBeenCalled()
46
+ })
47
+
48
+ it('a press on the scrim closes it; a press on the picture does not', async () => {
49
+ const onClose = vi.fn()
50
+ render(<Lightbox src={PICTURE} alt="a.png" onClose={onClose} />)
51
+
52
+ await userEvent.click(screen.getByAltText('a.png'))
53
+ expect(onClose).not.toHaveBeenCalled()
54
+
55
+ await userEvent.click(screen.getByRole('dialog'))
56
+ expect(onClose).toHaveBeenCalledTimes(1)
57
+ })
58
+
59
+ it('swapping the picture swaps what is on screen — a thumbnail, then the original', () => {
60
+ const { rerender } = render(<Lightbox src={PICTURE} alt="a.png" onClose={() => {}} />)
61
+ expect(screen.getByAltText('a.png').getAttribute('src')).toBe(PICTURE)
62
+ rerender(<Lightbox src="blob:original" alt="a.png" onClose={() => {}} />)
63
+ expect(screen.getByAltText('a.png').getAttribute('src')).toBe('blob:original')
64
+ })
65
+ })
@@ -0,0 +1,87 @@
1
+ import { useRef } from 'react'
2
+ import { Dialog } from '@base-ui/react/dialog'
3
+ import { IconX } from '@tabler/icons-react'
4
+ import { cn } from './cn'
5
+
6
+ /**
7
+ * A picture, full screen, on a scrim — the thing a thumbnail opens into.
8
+ *
9
+ * Both apps had written this by hand and neither had got it right: Peek's
10
+ * `ImageLightbox` and the `FrameLightbox` before it were `createPortal` plus a
11
+ * fixed `<div>`, with no Escape, no focus trap and nothing to return focus to
12
+ * (Peek scan F5, P4; migration B22, Katerina 10 September). Ship never had one
13
+ * at all, so a screenshot in an issue could not be looked at.
14
+ *
15
+ * On Base UI's `Dialog`, like `DialogShell`: the portal, the backdrop, Escape,
16
+ * the outside press and the focus trap are its, and what is written here is the
17
+ * picture, the scrim and the close button.
18
+ *
19
+ * **The close button is this component's own**, not an `IconButton`: a 26px
20
+ * square with no fill is hard to find on top of a photograph, which is why Peek
21
+ * had written an escape note beside its own. A 32px round button on an elevated
22
+ * fill with a hairline reads on any picture.
23
+ */
24
+ export interface LightboxProps {
25
+ /** The picture to show. Swapping it — a thumbnail for the original, once it
26
+ * arrives — swaps what is on screen, with no flash of nothing. */
27
+ src: string
28
+ /** What the picture is. Names the viewer for assistive tech too. */
29
+ alt: string
30
+ onClose: () => void
31
+ /** Extra classes on the picture itself. */
32
+ className?: string
33
+ }
34
+
35
+ export function Lightbox({ src, alt, onClose, className }: LightboxProps) {
36
+ const popupRef = useRef<HTMLDivElement>(null)
37
+ return (
38
+ <Dialog.Root
39
+ open
40
+ onOpenChange={(open) => {
41
+ if (!open) onClose()
42
+ }}
43
+ >
44
+ <Dialog.Portal>
45
+ <Dialog.Backdrop className="fixed inset-0 z-40 bg-scrim-strong" />
46
+ <Dialog.Popup
47
+ ref={popupRef}
48
+ /*
49
+ Focus the layer, not the ✕ — DialogShell's rule, for the same
50
+ reason: Base UI's default is the first tabbable element, which here
51
+ would open every picture with a ring drawn on its close button. The
52
+ trap is armed either way, and Tab reaches the ✕ first.
53
+ */
54
+ initialFocus={popupRef}
55
+ aria-label={alt}
56
+ /*
57
+ The popup IS the layer: a picture viewer has no card, so the popup
58
+ fills the screen and centres what it holds. `outline-none` because
59
+ it is a programmatic focus target rather than something tabbed to —
60
+ the same reason DialogShell gives.
61
+
62
+ A press anywhere on the layer closes it, which is what a full-screen
63
+ picture teaches people to expect. Base UI's outside-press cannot do
64
+ that here: there is no outside.
65
+ */
66
+ className="fixed inset-0 z-50 flex items-center justify-center p-8 outline-none"
67
+ onClick={onClose}
68
+ >
69
+ <img
70
+ src={src}
71
+ alt={alt}
72
+ /* The press on the picture is not a press on the scrim: clicking
73
+ the thing you opened should not close it. */
74
+ onClick={(e) => e.stopPropagation()}
75
+ className={cn('max-w-full max-h-full object-contain rounded-lg', className)}
76
+ />
77
+ <Dialog.Close
78
+ aria-label="Close"
79
+ className="absolute top-4 right-4 size-8 rounded-full bg-bg-elevated border border-border-strong flex items-center justify-center text-text-secondary hover:text-text-primary"
80
+ >
81
+ <IconX size={16} stroke={1.5} />
82
+ </Dialog.Close>
83
+ </Dialog.Popup>
84
+ </Dialog.Portal>
85
+ </Dialog.Root>
86
+ )
87
+ }
@@ -190,7 +190,7 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
190
190
  id: 'portal',
191
191
  behaviour: 'Floats on top of the page',
192
192
  baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Autocomplete', 'Tooltip', 'PreviewCard', 'Toast'],
193
- owners: ['DialogShell', 'ConfirmDialog', 'CommandPalette', 'Popover', 'Menu', 'Select', 'ChipInput', 'Tooltip', 'PreviewCard', 'Toast'],
193
+ owners: ['DialogShell', 'ConfirmDialog', 'CommandPalette', 'Lightbox', 'Popover', 'Menu', 'Select', 'ChipInput', 'Tooltip', 'PreviewCard', 'Toast'],
194
194
  reads: 'createPortal, called, or imported and never called',
195
195
  },
196
196
  {
@@ -204,21 +204,21 @@ export const OWNED_BEHAVIOURS: OwnedBehaviour[] = [
204
204
  id: 'page-keys',
205
205
  behaviour: 'Takes its keys by itself',
206
206
  baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox', 'Tooltip', 'PreviewCard', 'Toast'],
207
- owners: ['DialogShell', 'Popover', 'Menu', 'Select', 'Tabs', 'Toolbar'],
207
+ owners: ['DialogShell', 'Lightbox', 'Popover', 'Menu', 'Select', 'Tabs', 'Toolbar'],
208
208
  reads: 'a keydown, keyup or keypress listener on window or document',
209
209
  },
210
210
  {
211
211
  id: 'focus',
212
212
  behaviour: 'Holds focus inside while open, and gives it back',
213
213
  baseUi: ['Dialog', 'AlertDialog', 'Popover', 'Menu', 'Select', 'Combobox'],
214
- owners: ['DialogShell', 'CommandPalette'],
214
+ owners: ['DialogShell', 'CommandPalette', 'Lightbox'],
215
215
  reads: 'a focusin or focusout listener on window or document; the Tab key compared by hand',
216
216
  },
217
217
  {
218
218
  id: 'scroll-lock',
219
219
  behaviour: 'Stops the page behind it scrolling',
220
220
  baseUi: ['Dialog', 'AlertDialog'],
221
- owners: ['DialogShell'],
221
+ owners: ['DialogShell', 'Lightbox'],
222
222
  reads: 'overflow written into the style of document.body or document.documentElement',
223
223
  },
224
224
  {