@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.json CHANGED
@@ -4,9 +4,9 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.25.0",
8
- "exports": 86,
9
- "typeExports": 95,
7
+ "packageVersion": "0.26.0",
8
+ "exports": 87,
9
+ "typeExports": 96,
10
10
  "files": null
11
11
  },
12
12
  "storybook": {
@@ -129,11 +129,35 @@
129
129
  "required": false,
130
130
  "note": "The picture an image card draws. Without it an image is drawn as a row."
131
131
  },
132
+ {
133
+ "name": "alt",
134
+ "takes": "text",
135
+ "required": false,
136
+ "note": "What the picture shows, where the sender said — Ship carries one on every `imeta`."
137
+ },
138
+ {
139
+ "name": "remoteSrc",
140
+ "takes": "text",
141
+ "required": false,
142
+ "note": "A file the app has to *fetch* before it can be used — one on a relay, where reading it needs the reader's own authorization (UIG-35)."
143
+ },
144
+ {
145
+ "name": "remoteFullSrc",
146
+ "takes": "text",
147
+ "required": false,
148
+ "note": "The full-size picture, fetched only when someone opens it."
149
+ },
150
+ {
151
+ "name": "fetchImage",
152
+ "takes": "a handler",
153
+ "required": false,
154
+ "note": "How this app fetches a picture it is allowed to read: a URL in, a URL the browser can draw out (an object URL, usually)."
155
+ },
132
156
  {
133
157
  "name": "onOpen",
134
158
  "takes": "a handler",
135
159
  "required": false,
136
- "note": "What a click on an image's picture does — the app's full-screen view."
160
+ "note": "What a click on an image's picture does."
137
161
  },
138
162
  {
139
163
  "name": "onDownload",
@@ -141,6 +165,12 @@
141
165
  "required": false,
142
166
  "note": "Shows a download control on hover or focus, and calls this."
143
167
  },
168
+ {
169
+ "name": "download",
170
+ "takes": "true/false",
171
+ "required": false,
172
+ "note": "Shows the download control and saves the file itself, under `name`: the original where there is one (`remoteFullSrc`), otherwise what the card is drawing."
173
+ },
144
174
  {
145
175
  "name": "pending",
146
176
  "takes": "true/false",
@@ -2370,6 +2400,66 @@
2370
2400
  "docPage": "src/Kbd.mdx",
2371
2401
  "app": null
2372
2402
  },
2403
+ {
2404
+ "name": "Lightbox",
2405
+ "repo": "estiva-ui",
2406
+ "kind": "component",
2407
+ "importPath": "@estiva-app/ui",
2408
+ "sourceFile": "src/Lightbox.tsx",
2409
+ "purpose": "A picture, full screen, on a scrim — what a thumbnail opens into.",
2410
+ "purposeFrom": "page",
2411
+ "props": [
2412
+ {
2413
+ "name": "src",
2414
+ "takes": "text",
2415
+ "required": true,
2416
+ "note": "The picture to show. Swapping it — a thumbnail for the original, once it arrives — swaps what is on screen, with no flash of nothing."
2417
+ },
2418
+ {
2419
+ "name": "alt",
2420
+ "takes": "text",
2421
+ "required": true,
2422
+ "note": "What the picture is. Names the viewer for assistive tech too."
2423
+ },
2424
+ {
2425
+ "name": "onClose",
2426
+ "takes": "a handler",
2427
+ "required": true,
2428
+ "note": null
2429
+ },
2430
+ {
2431
+ "name": "className",
2432
+ "takes": "text",
2433
+ "required": false,
2434
+ "note": "Extra classes on the picture itself."
2435
+ }
2436
+ ],
2437
+ "variants": [],
2438
+ "ownsBehaviours": [
2439
+ {
2440
+ "id": "portal",
2441
+ "behaviour": "Floats on top of the page"
2442
+ },
2443
+ {
2444
+ "id": "page-keys",
2445
+ "behaviour": "Takes its keys by itself"
2446
+ },
2447
+ {
2448
+ "id": "focus",
2449
+ "behaviour": "Holds focus inside while open, and gives it back"
2450
+ },
2451
+ {
2452
+ "id": "scroll-lock",
2453
+ "behaviour": "Stops the page behind it scrolling"
2454
+ }
2455
+ ],
2456
+ "status": "stable",
2457
+ "migrationStage": null,
2458
+ "docsId": "overlays-lightbox--docs",
2459
+ "storyId": "overlays-lightbox--a-picture",
2460
+ "docPage": "src/Lightbox.mdx",
2461
+ "app": null
2462
+ },
2373
2463
  {
2374
2464
  "name": "Link",
2375
2465
  "repo": "estiva-ui",
@@ -14,11 +14,14 @@ waiting to be sent.
14
14
 
15
15
  The file decides the shape:
16
16
 
17
- - **an image with a picture** (`src`) — a 180px thumbnail; `onOpen` is what a
18
- click on the picture does.
17
+ - **an image with a picture** (`src`) — a 180px thumbnail, which opens full
18
+ screen when it is pressed. `onOpen` replaces that with the app's own.
19
19
  - **anything else** — a 240px row: a type tile, the name, the type and size.
20
20
  `href` opens it in a new tab; without one the row is dimmed.
21
- - `onDownload` — a download control that fades in on hover or keyboard focus.
21
+ - `download` — a download control that fades in on hover or keyboard focus, and
22
+ the card saves the file. `onDownload` instead when the app must do it itself.
23
+ - `remoteSrc` + `fetchImage` — a picture the reader needs permission for. The
24
+ card waits and says so if it is refused; `state` says the same by hand.
22
25
  - `state="loading"` — an image on its way. `state="unreadable"` — it could not
23
26
  be read: said, with a dashed hairline, never left as a gap.
24
27
  - **`pending`** — waiting to be sent: a 200px row with a remove control on its
@@ -39,15 +42,30 @@ The file decides the shape:
39
42
  ```tsx
40
43
  import { AttachmentCard } from '@estiva-app/ui'
41
44
 
42
- // Posted: the app fetched the picture, and opens it full screen itself.
43
- <AttachmentCard name={name} size={bytes} contentType={type} src={pictureUrl} onOpen={openFullScreen} onDownload={save} />
45
+ // Posted, with the picture already in hand.
46
+ <AttachmentCard name={name} size={bytes} contentType={type} src={pictureUrl} download />
47
+
48
+ // Posted, on a relay: the card fetches it with your app's authorization, opens
49
+ // it full screen and saves it. The original is fetched only when it is opened.
50
+ <AttachmentCard
51
+ name={name}
52
+ size={bytes}
53
+ contentType={type}
54
+ remoteSrc={thumbUrl ?? mediaUrl}
55
+ remoteFullSrc={mediaUrl}
56
+ fetchImage={blobObjectUrl}
57
+ download
58
+ />
44
59
 
45
60
  // Waiting to be sent.
46
61
  <AttachmentCard pending name={name} size={bytes} state={uploading ? 'uploading' : 'ready'} src={localPreview} onRemove={remove} />
47
62
  ```
48
63
 
49
- - The card fetches nothing. Reading a file may need the reader's own permission,
50
- and only the app has it: pass the result — `src`, `href`, `state`.
64
+ - **How to fetch is the app's, what to draw is the card's.** `fetchImage` takes
65
+ a URL and returns one the browser can draw — an object URL, usually. The
66
+ card owns the wait, the refusal and the swap; it knows nothing about relays,
67
+ tokens or which app it is in.
68
+ - A picture already in hand needs none of that: pass `src`.
51
69
  - A click on the picture, the download control and the remove control each stop
52
70
  there, so they never also click the card or row the attachment sits in.
53
71
  - The name truncates. When it is cut off, the full name is on hover; a name
@@ -57,7 +75,10 @@ import { AttachmentCard } from '@estiva-app/ui'
57
75
  on Base UI's `Button` like InputChip's ✕ — not an `IconButton`, the 24px
58
76
  square that fills on hover.
59
77
 
60
- - **Planned:** a Lightbox that opens a picture full screen, in migration stage 7.
78
+ - A press on the picture opens it full screen, in **Lightbox**, unless `onOpen`
79
+ says what else to do.
80
+
81
+ <Canvas of={AttachmentCardStories.OpenItFullScreen} />
61
82
 
62
83
  ## What it owns
63
84
 
@@ -67,6 +88,9 @@ It does these by itself. Write none of them around it.
67
88
  |---|---|---|
68
89
  | Stops the clicks on its picture, its download and its remove control, so they never also click the card or row around it | `stopPropagation` | |
69
90
  | Shows the full name on hover when the name is cut off | a tooltip of your own | |
91
+ | Opens the picture full screen, in Lightbox | a viewer of your own, a portal, a scrim | |
92
+ | Fetches a picture that needs permission, and draws the wait and the refusal | a loading hook of your own | |
93
+ | Saves the file under its name, the original rather than the thumbnail | a `<a download>` of your own | |
70
94
 
71
95
  ## Props
72
96
 
@@ -90,3 +90,57 @@ export const Failed: Story = { args: { ...Pending.args, state: 'failed', name: '
90
90
 
91
91
  /** Attached, with something the sender should know: a warning, not an error — nothing failed. The full words are on hover. */
92
92
  export const Warning: Story = { args: { ...Pending.args, state: 'warning', note: 'Not everyone can see this', noteHint: 'Only this app has a copy of this file.' } }
93
+
94
+ /**
95
+ * **Press the picture.** Without an `onOpen` the card opens it itself, full
96
+ * screen, in the package's `Lightbox` — Escape, the ✕ or the scrim closes it
97
+ * (UIG-35). Peek had written that viewer by hand and Ship had none at all.
98
+ */
99
+ export const OpenItFullScreen: Story = {
100
+ parameters: {
101
+ layout: 'fullscreen',
102
+ // The viewer portals over the page — iframe it so it does not cover the docs.
103
+ docs: { story: { inline: false, height: '420px' } },
104
+ },
105
+ args: { name: 'checkout-flow.png', size: 840_000, contentType: 'image/png', src: WIDE, href: undefined, onOpen: undefined },
106
+ render: (args) => (
107
+ <div className="p-4">
108
+ <AttachmentCard {...args} />
109
+ </div>
110
+ ),
111
+ }
112
+
113
+ /**
114
+ * **A picture the reader has to be allowed to see**, which is every file on the
115
+ * relay: `remoteSrc` is where it lives and `fetchImage` is how this app fetches
116
+ * one with the reader's own authorization. The card waits, then draws it.
117
+ *
118
+ * Here the fetch takes a second, so the wait is visible. The two apps had
119
+ * written this same wait, once each.
120
+ */
121
+ export const FetchedWithPermission: Story = {
122
+ parameters: { controls: { disable: true } },
123
+ args: {
124
+ name: 'checkout-flow.png',
125
+ size: 840_000,
126
+ contentType: 'image/png',
127
+ src: undefined,
128
+ href: undefined,
129
+ remoteSrc: 'relay://checkout-flow.png',
130
+ fetchImage: () => new Promise<string>((resolve) => setTimeout(() => resolve(WIDE), 1000)),
131
+ },
132
+ }
133
+
134
+ /** The same request, refused — a channel this reader is not in. The card says so, and does not leave a gap. */
135
+ export const CouldNotBeFetched: Story = {
136
+ parameters: { controls: { disable: true } },
137
+ args: {
138
+ name: 'checkout-flow.png',
139
+ size: 840_000,
140
+ contentType: 'image/png',
141
+ src: undefined,
142
+ href: undefined,
143
+ remoteSrc: 'relay://checkout-flow.png',
144
+ fetchImage: () => Promise.reject(new Error('relay_membership_required')),
145
+ },
146
+ }
@@ -179,4 +179,119 @@ describe('AttachmentCard', () => {
179
179
  await userEvent.tab()
180
180
  expect(document.activeElement).toBe(remove)
181
181
  })
182
+
183
+ /* UIG-35: what the two apps were doing around the card, and it now does. */
184
+
185
+ it('a picture that needs permission: the card waits, then draws what the app fetched', async () => {
186
+ const fetchImage = vi.fn().mockResolvedValue('blob:the-picture')
187
+ render(<AttachmentCard name="shot.png" contentType="image/png" remoteSrc="relay://shot" fetchImage={fetchImage} />)
188
+
189
+ // While it is in flight the card is the loading card, not a gap.
190
+ expect(screen.getByRole('status', { name: 'Loading shot.png' })).toBeTruthy()
191
+
192
+ const picture = await screen.findByAltText('shot.png')
193
+ expect(picture.getAttribute('src')).toBe('blob:the-picture')
194
+ expect(fetchImage).toHaveBeenCalledWith('relay://shot')
195
+ })
196
+
197
+ it('a document that has to be fetched keeps its row, and opens once the bytes land', async () => {
198
+ const fetchImage = vi.fn().mockResolvedValue('blob:the-file')
199
+ const { container } = render(<AttachmentCard name="report.pdf" size={2_412_000} remoteSrc="relay://report" fetchImage={fetchImage} />)
200
+
201
+ // Not the picture's loading card: the row is already saying what the file is.
202
+ expect(screen.getByText('report.pdf')).toBeTruthy()
203
+ expect(screen.queryByRole('status')).toBeNull()
204
+ expect(classesOf(rootOf(container))).toContain('w-[240px]')
205
+ expect(screen.queryByRole('link')).toBeNull()
206
+
207
+ await vi.waitFor(() => expect(screen.getByRole('link').getAttribute('href')).toBe('blob:the-file'))
208
+ })
209
+
210
+ it("the sender's words about a picture are its alt text, and name it in the viewer", async () => {
211
+ render(<AttachmentCard name="shot.png" contentType="image/png" src="blob:thumb" alt="the failing dialog" />)
212
+ expect(screen.getByAltText('the failing dialog')).toBeTruthy()
213
+ // The file's name still sits under the thumbnail: the two say different things.
214
+ expect(screen.getByText('shot.png')).toBeTruthy()
215
+
216
+ await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' }))
217
+ expect(screen.getByRole('dialog', { name: 'the failing dialog' })).toBeTruthy()
218
+ })
219
+
220
+ it('a refused request is said, with the unreadable card', async () => {
221
+ render(
222
+ <AttachmentCard name="shot.png" contentType="image/png" remoteSrc="relay://shot" fetchImage={() => Promise.reject(new Error('relay_membership_required'))} />,
223
+ )
224
+ expect(await screen.findByText('Could not be loaded')).toBeTruthy()
225
+ })
226
+
227
+ it('a fetcher written inline does not re-fetch on every render', async () => {
228
+ const calls = { n: 0 }
229
+ // A new function identity on every render of the app around it, which is
230
+ // what an inline `fetchImage={…}` is. Only the URL may start a fetch.
231
+ const Harness = ({ tick }: { tick: number }) => (
232
+ <AttachmentCard
233
+ name="shot.png"
234
+ contentType="image/png"
235
+ remoteSrc="relay://shot"
236
+ data-tick={tick}
237
+ fetchImage={() => {
238
+ calls.n += 1
239
+ return Promise.resolve('blob:the-picture')
240
+ }}
241
+ />
242
+ )
243
+ const { rerender } = render(<Harness tick={1} />)
244
+ await screen.findByAltText('shot.png')
245
+ rerender(<Harness tick={2} />)
246
+ rerender(<Harness tick={3} />)
247
+ expect(calls.n).toBe(1)
248
+ })
249
+
250
+ it('pressing the picture opens it full screen, and Escape closes it', async () => {
251
+ render(<AttachmentCard name="shot.png" contentType="image/png" src="blob:thumb" />)
252
+ await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' }))
253
+
254
+ const viewer = screen.getByRole('dialog', { name: 'shot.png' })
255
+ expect(viewer).toBeTruthy()
256
+
257
+ await userEvent.keyboard('{Escape}')
258
+ expect(screen.queryByRole('dialog')).toBeNull()
259
+ })
260
+
261
+ it('an app that wants its own viewer still gets the click, and no viewer opens', async () => {
262
+ const onOpen = vi.fn()
263
+ render(<AttachmentCard name="shot.png" contentType="image/png" src="blob:thumb" onOpen={onOpen} />)
264
+ await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' }))
265
+ expect(onOpen).toHaveBeenCalledTimes(1)
266
+ expect(screen.queryByRole('dialog')).toBeNull()
267
+ })
268
+
269
+ it('opening a relay picture fetches the original, and shows the thumbnail until it lands', async () => {
270
+ let landOriginal: (src: string) => void = () => {}
271
+ const fetchImage = vi.fn((url: string) =>
272
+ url === 'relay://original' ? new Promise<string>((resolve) => (landOriginal = resolve)) : Promise.resolve('blob:thumb'),
273
+ )
274
+ render(
275
+ <AttachmentCard name="shot.png" contentType="image/png" remoteSrc="relay://thumb" remoteFullSrc="relay://original" fetchImage={fetchImage} />,
276
+ )
277
+ await screen.findByAltText('shot.png')
278
+ await userEvent.click(screen.getByRole('button', { name: 'Preview shot.png' }))
279
+
280
+ // The thumbnail stands in: a moment of a smaller picture beats a moment of an empty screen.
281
+ expect(screen.getByRole('dialog').querySelector('img')?.getAttribute('src')).toBe('blob:thumb')
282
+
283
+ landOriginal('blob:original')
284
+ await vi.waitFor(() => expect(screen.getByRole('dialog').querySelector('img')?.getAttribute('src')).toBe('blob:original'))
285
+ })
286
+
287
+ it('the download control saves the original, not the thumbnail on screen', async () => {
288
+ const fetchImage = vi.fn().mockResolvedValue('blob:the-picture')
289
+ render(
290
+ <AttachmentCard name="shot.png" contentType="image/png" remoteSrc="relay://thumb" remoteFullSrc="relay://original" fetchImage={fetchImage} download />,
291
+ )
292
+ await screen.findByAltText('shot.png')
293
+ fetchImage.mockClear()
294
+ await userEvent.click(screen.getByRole('button', { name: 'Download shot.png' }))
295
+ expect(fetchImage).toHaveBeenCalledWith('relay://original')
296
+ })
182
297
  })