@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/package.json
CHANGED
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.
|
|
8
|
-
"exports":
|
|
9
|
-
"typeExports":
|
|
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
|
|
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",
|
|
@@ -1680,6 +1710,49 @@
|
|
|
1680
1710
|
"docPage": null,
|
|
1681
1711
|
"app": null
|
|
1682
1712
|
},
|
|
1713
|
+
{
|
|
1714
|
+
"name": "ErrorBoundary",
|
|
1715
|
+
"repo": "estiva-ui",
|
|
1716
|
+
"kind": "component",
|
|
1717
|
+
"importPath": "@estiva-app/ui",
|
|
1718
|
+
"sourceFile": "src/ErrorBoundary.tsx",
|
|
1719
|
+
"purpose": "Keeps a crash inside what it holds: that part shows \"Something went wrong … Your data is safe — try again.\" with a Try again button, and the rest of the page keeps working.",
|
|
1720
|
+
"purposeFrom": "page",
|
|
1721
|
+
"props": [
|
|
1722
|
+
{
|
|
1723
|
+
"name": "children",
|
|
1724
|
+
"takes": "anything",
|
|
1725
|
+
"required": true,
|
|
1726
|
+
"note": null
|
|
1727
|
+
},
|
|
1728
|
+
{
|
|
1729
|
+
"name": "label",
|
|
1730
|
+
"takes": "text",
|
|
1731
|
+
"required": false,
|
|
1732
|
+
"note": "What it holds, said in the message and the console line: \"Something went wrong in the {label}.\""
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
"name": "fallbackClassName",
|
|
1736
|
+
"takes": "text",
|
|
1737
|
+
"required": false,
|
|
1738
|
+
"note": "The room the message fills where no flex column holds it — the whole screen, for the app's own."
|
|
1739
|
+
},
|
|
1740
|
+
{
|
|
1741
|
+
"name": "frame",
|
|
1742
|
+
"takes": "a handler",
|
|
1743
|
+
"required": false,
|
|
1744
|
+
"note": "Draws the message inside a frame of yours, in the same place: `ListColumn` keeps its title this way."
|
|
1745
|
+
}
|
|
1746
|
+
],
|
|
1747
|
+
"variants": [],
|
|
1748
|
+
"ownsBehaviours": [],
|
|
1749
|
+
"status": "stable",
|
|
1750
|
+
"migrationStage": null,
|
|
1751
|
+
"docsId": "feedback-errorboundary--docs",
|
|
1752
|
+
"storyId": "feedback-errorboundary--in-a-panel",
|
|
1753
|
+
"docPage": "src/ErrorBoundary.mdx",
|
|
1754
|
+
"app": null
|
|
1755
|
+
},
|
|
1683
1756
|
{
|
|
1684
1757
|
"name": "Field",
|
|
1685
1758
|
"repo": "estiva-ui",
|
|
@@ -2327,6 +2400,66 @@
|
|
|
2327
2400
|
"docPage": "src/Kbd.mdx",
|
|
2328
2401
|
"app": null
|
|
2329
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
|
+
},
|
|
2330
2463
|
{
|
|
2331
2464
|
"name": "Link",
|
|
2332
2465
|
"repo": "estiva-ui",
|
|
@@ -2428,12 +2561,6 @@
|
|
|
2428
2561
|
"required": false,
|
|
2429
2562
|
"note": "A row between the header and the list that stays put while the list scrolls — a field that adds to the list."
|
|
2430
2563
|
},
|
|
2431
|
-
{
|
|
2432
|
-
"name": "spacing",
|
|
2433
|
-
"takes": "rows | sections",
|
|
2434
|
-
"required": false,
|
|
2435
|
-
"note": "The room between rows. `rows` (2px) for a list of one kind of row; `sections` (4px) for groups with labels and dividers of their own."
|
|
2436
|
-
},
|
|
2437
2564
|
{
|
|
2438
2565
|
"name": "collapsed",
|
|
2439
2566
|
"takes": "true/false",
|
|
@@ -2444,7 +2571,7 @@
|
|
|
2444
2571
|
"name": "children",
|
|
2445
2572
|
"takes": "anything",
|
|
2446
2573
|
"required": true,
|
|
2447
|
-
"note": "The rows."
|
|
2574
|
+
"note": "The rows, 2px apart. Groups are headed by a `CollapsibleSection` or a `SectionHeader`."
|
|
2448
2575
|
},
|
|
2449
2576
|
{
|
|
2450
2577
|
"name": "className",
|
|
@@ -2453,15 +2580,7 @@
|
|
|
2453
2580
|
"note": "Placement only."
|
|
2454
2581
|
}
|
|
2455
2582
|
],
|
|
2456
|
-
"variants": [
|
|
2457
|
-
{
|
|
2458
|
-
"prop": "spacing",
|
|
2459
|
-
"values": [
|
|
2460
|
-
"rows",
|
|
2461
|
-
"sections"
|
|
2462
|
-
]
|
|
2463
|
-
}
|
|
2464
|
-
],
|
|
2583
|
+
"variants": [],
|
|
2465
2584
|
"ownsBehaviours": [],
|
|
2466
2585
|
"status": "stable",
|
|
2467
2586
|
"migrationStage": null,
|
|
@@ -2766,6 +2885,24 @@
|
|
|
2766
2885
|
"docPage": null,
|
|
2767
2886
|
"app": null
|
|
2768
2887
|
},
|
|
2888
|
+
{
|
|
2889
|
+
"name": "MenuSeparator",
|
|
2890
|
+
"repo": "estiva-ui",
|
|
2891
|
+
"kind": "component",
|
|
2892
|
+
"importPath": "@estiva-app/ui",
|
|
2893
|
+
"sourceFile": "src/Menu.tsx",
|
|
2894
|
+
"purpose": "The line between two groups of a menu, with its own 4px above and below (UIG-14, Katerina 19 September).",
|
|
2895
|
+
"purposeFrom": "comment",
|
|
2896
|
+
"props": [],
|
|
2897
|
+
"variants": [],
|
|
2898
|
+
"ownsBehaviours": [],
|
|
2899
|
+
"status": "stable",
|
|
2900
|
+
"migrationStage": null,
|
|
2901
|
+
"docsId": "overlays-menu--docs",
|
|
2902
|
+
"storyId": "overlays-menu--items",
|
|
2903
|
+
"docPage": null,
|
|
2904
|
+
"app": null
|
|
2905
|
+
},
|
|
2769
2906
|
{
|
|
2770
2907
|
"name": "MenuSub",
|
|
2771
2908
|
"repo": "estiva-ui",
|
package/src/AttachmentCard.mdx
CHANGED
|
@@ -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
|
|
18
|
-
|
|
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
|
-
- `
|
|
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
|
|
43
|
-
<AttachmentCard name={name} size={bytes} contentType={type} src={pictureUrl}
|
|
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
|
-
-
|
|
50
|
-
and
|
|
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
|
-
-
|
|
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
|
})
|