@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.
- package/dist/AttachmentCard.d.ts +48 -6
- package/dist/AttachmentCard.d.ts.map +1 -1
- package/dist/Lightbox.d.ts +30 -0
- package/dist/Lightbox.d.ts.map +1 -0
- package/dist/eslint/index.js +4 -4
- package/dist/eslint/index.js.map +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +594 -464
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/registry.json +94 -4
- 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/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/eslint/no-rebuilt-behaviour.ts +4 -4
- package/src/index.ts +1 -0
- package/src/registry/registry.test.ts +4 -4
- 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
|
}
|
package/src/Lightbox.mdx
ADDED
|
@@ -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
|
+
})
|
package/src/Lightbox.tsx
ADDED
|
@@ -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
|
{
|