@remit/ui 0.0.52 → 0.0.54
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 +1 -1
- package/src/components/label-chip.stories.tsx +72 -0
- package/src/components/message-row.stories.tsx +132 -0
- package/src/components/selection-sheet.render.test.ts +30 -0
- package/src/components/selection-sheet.stories.tsx +21 -0
- package/src/components/selection-sheet.tsx +25 -12
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { labelColorOptions } from "../lib/label-color.js";
|
|
3
|
+
import { LabelChip } from "./label-chip.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof LabelChip> = {
|
|
6
|
+
title: "Primitives/LabelChip",
|
|
7
|
+
component: LabelChip,
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
type Story = StoryObj<typeof LabelChip>;
|
|
13
|
+
|
|
14
|
+
/** A chip in each color the picker offers — the dot is the only thing that varies. */
|
|
15
|
+
export const AllColors: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<div className="flex flex-wrap gap-2">
|
|
18
|
+
{labelColorOptions.map((color) => (
|
|
19
|
+
<LabelChip
|
|
20
|
+
key={color}
|
|
21
|
+
label={{ labelId: `lbl-${color}`, name: color, color }}
|
|
22
|
+
/>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Same colors against the dark theme, so the dot keeps contrast either way. */
|
|
29
|
+
export const AllColorsDark: Story = {
|
|
30
|
+
name: "All Colors (dark)",
|
|
31
|
+
parameters: { theme: "dark" },
|
|
32
|
+
render: () => (
|
|
33
|
+
<div className="flex flex-wrap gap-2">
|
|
34
|
+
{labelColorOptions.map((color) => (
|
|
35
|
+
<LabelChip
|
|
36
|
+
key={color}
|
|
37
|
+
label={{ labelId: `lbl-${color}`, name: color, color }}
|
|
38
|
+
/>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** A long name truncates rather than growing the chip. */
|
|
45
|
+
export const LongName: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
label: {
|
|
48
|
+
labelId: "lbl-long",
|
|
49
|
+
name: "Quarterly compliance filings that need a second look",
|
|
50
|
+
color: "Purple",
|
|
51
|
+
},
|
|
52
|
+
className: "max-w-40",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The removable variant — the manual "just these" unlabel action. */
|
|
57
|
+
export const Removable: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
label: { labelId: "lbl-receipts", name: "Receipts", color: "Blue" },
|
|
60
|
+
onRemove: () => undefined,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Removable, on the dark theme. */
|
|
65
|
+
export const RemovableDark: Story = {
|
|
66
|
+
name: "Removable (dark)",
|
|
67
|
+
parameters: { theme: "dark" },
|
|
68
|
+
args: {
|
|
69
|
+
label: { labelId: "lbl-receipts", name: "Receipts", color: "Blue" },
|
|
70
|
+
onRemove: () => undefined,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -73,6 +73,81 @@ const withCategory: ThreadRowData = {
|
|
|
73
73
|
category: "newsletter",
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
const noLabel: ThreadRowData = {
|
|
77
|
+
id: "r-no-label",
|
|
78
|
+
accountId: "a1",
|
|
79
|
+
fromName: "Jordan Lee",
|
|
80
|
+
fromEmail: "jordan@example.com",
|
|
81
|
+
subject: "Lunch on Friday?",
|
|
82
|
+
snippet: "Thinking the usual place, around noon.",
|
|
83
|
+
timeLabel: "10:03",
|
|
84
|
+
isRead: true,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const oneLabel: ThreadRowData = {
|
|
88
|
+
id: "r-one-label",
|
|
89
|
+
accountId: "a1",
|
|
90
|
+
fromName: "Stripe",
|
|
91
|
+
fromEmail: "receipts@stripe.com",
|
|
92
|
+
subject: "Your receipt from Acme Co",
|
|
93
|
+
snippet: "Payment of $42.00 was successful.",
|
|
94
|
+
timeLabel: "9:10",
|
|
95
|
+
isRead: true,
|
|
96
|
+
labels: [{ labelId: "l1", name: "Receipts", color: "Blue" }],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const twoLabels: ThreadRowData = {
|
|
100
|
+
id: "r-two-labels",
|
|
101
|
+
accountId: "a1",
|
|
102
|
+
fromName: "United Airlines",
|
|
103
|
+
fromEmail: "noreply@united.com",
|
|
104
|
+
subject: "Your itinerary for SFO → JFK",
|
|
105
|
+
snippet: "Check-in opens 24 hours before departure.",
|
|
106
|
+
timeLabel: "Yesterday",
|
|
107
|
+
isRead: false,
|
|
108
|
+
labels: [
|
|
109
|
+
{ labelId: "l1", name: "Receipts", color: "Blue" },
|
|
110
|
+
{ labelId: "l2", name: "Travel", color: "Green" },
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const severalLabels: ThreadRowData = {
|
|
115
|
+
id: "r-several-labels",
|
|
116
|
+
accountId: "a1",
|
|
117
|
+
fromName: "Finance Team",
|
|
118
|
+
fromEmail: "finance@example.com",
|
|
119
|
+
subject: "Q3 budget review — action needed",
|
|
120
|
+
snippet: "Please review the attached numbers before Thursday.",
|
|
121
|
+
timeLabel: "Mon",
|
|
122
|
+
isRead: false,
|
|
123
|
+
labels: [
|
|
124
|
+
{ labelId: "l1", name: "Receipts", color: "Blue" },
|
|
125
|
+
{ labelId: "l2", name: "Travel", color: "Green" },
|
|
126
|
+
{ labelId: "l3", name: "Urgent", color: "Red" },
|
|
127
|
+
{ labelId: "l4", name: "Work", color: "Purple" },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const longLabelName: ThreadRowData = {
|
|
132
|
+
id: "r-long-label",
|
|
133
|
+
accountId: "a1",
|
|
134
|
+
fromName: "Compliance",
|
|
135
|
+
fromEmail: "compliance@example.com",
|
|
136
|
+
subject: "Filing due end of quarter",
|
|
137
|
+
snippet: "One outstanding item on the checklist.",
|
|
138
|
+
timeLabel: "Tue",
|
|
139
|
+
isRead: true,
|
|
140
|
+
labels: [
|
|
141
|
+
{
|
|
142
|
+
labelId: "l5",
|
|
143
|
+
name: "Quarterly compliance filings that need a second look",
|
|
144
|
+
color: "Purple",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const labeled = [noLabel, oneLabel, twoLabels, severalLabels, longLabelName];
|
|
150
|
+
|
|
76
151
|
const all = [read, unread, starred, suspicious, withAttachment, withCategory];
|
|
77
152
|
|
|
78
153
|
const meta: Meta = {
|
|
@@ -149,3 +224,60 @@ export const Selectable: Story = {
|
|
|
149
224
|
</List>
|
|
150
225
|
),
|
|
151
226
|
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Labels (issue #26) alongside the existing read/unread, attachment and
|
|
230
|
+
* category affordances — no label, one, two, several, and a long name that
|
|
231
|
+
* truncates rather than growing the row. Comfortable density renders the
|
|
232
|
+
* chips; compact does not (see `CompactLabels` below).
|
|
233
|
+
*/
|
|
234
|
+
export const ComfortableLabels: Story = {
|
|
235
|
+
render: () => (
|
|
236
|
+
<List>
|
|
237
|
+
{labeled.map((thread) => (
|
|
238
|
+
<ComfortableRow key={thread.id} thread={thread} />
|
|
239
|
+
))}
|
|
240
|
+
</List>
|
|
241
|
+
),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** The same labeled threads on the dark theme. */
|
|
245
|
+
export const ComfortableLabelsDark: Story = {
|
|
246
|
+
name: "Comfortable Labels (dark)",
|
|
247
|
+
parameters: { theme: "dark" },
|
|
248
|
+
render: () => (
|
|
249
|
+
<List>
|
|
250
|
+
{labeled.map((thread) => (
|
|
251
|
+
<ComfortableRow key={thread.id} thread={thread} />
|
|
252
|
+
))}
|
|
253
|
+
</List>
|
|
254
|
+
),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The same labeled threads in compact density. `CompactRowBody` carries no
|
|
259
|
+
* label rendering today — this documents that as the approved current
|
|
260
|
+
* behavior, not an oversight in the story.
|
|
261
|
+
*/
|
|
262
|
+
export const CompactLabels: Story = {
|
|
263
|
+
render: () => (
|
|
264
|
+
<List>
|
|
265
|
+
{labeled.map((thread) => (
|
|
266
|
+
<CompactRow key={thread.id} thread={thread} />
|
|
267
|
+
))}
|
|
268
|
+
</List>
|
|
269
|
+
),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** Compact density, dark theme. */
|
|
273
|
+
export const CompactLabelsDark: Story = {
|
|
274
|
+
name: "Compact Labels (dark)",
|
|
275
|
+
parameters: { theme: "dark" },
|
|
276
|
+
render: () => (
|
|
277
|
+
<List>
|
|
278
|
+
{labeled.map((thread) => (
|
|
279
|
+
<CompactRow key={thread.id} thread={thread} />
|
|
280
|
+
))}
|
|
281
|
+
</List>
|
|
282
|
+
),
|
|
283
|
+
};
|
|
@@ -120,6 +120,36 @@ describe("SelectionSheet", () => {
|
|
|
120
120
|
assert.doesNotMatch(html, /Select similar messages/);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* jsdom lays nothing out, so these assert the constraint itself rather than a
|
|
125
|
+
* measured height: the sheet must not carry a fixed pixel ceiling (which sat
|
|
126
|
+
* below its own content at 411×759 and clipped the last action), and its body
|
|
127
|
+
* must scroll rather than clip whatever the ceiling does cut off (#405).
|
|
128
|
+
*/
|
|
129
|
+
it("caps the expanded height against the viewport, not a fixed pixel ceiling", () => {
|
|
130
|
+
const html = renderToString(
|
|
131
|
+
createElement(SelectionSheet, { ...base, startExpanded: true }),
|
|
132
|
+
);
|
|
133
|
+
assert.match(html, /max-height:70dvh/);
|
|
134
|
+
assert.doesNotMatch(html, /max-height:min\(/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("scrolls the expanded actions instead of clipping them", () => {
|
|
138
|
+
const html = renderToString(
|
|
139
|
+
createElement(SelectionSheet, {
|
|
140
|
+
...base,
|
|
141
|
+
startExpanded: true,
|
|
142
|
+
onSelectSimilar: noop,
|
|
143
|
+
onSomethingElse: noop,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
assert.match(html, /class="flex min-h-0 flex-1 flex-col overflow-y-auto /);
|
|
147
|
+
assert.doesNotMatch(
|
|
148
|
+
html,
|
|
149
|
+
/class="flex min-h-0 flex-1 flex-col overflow-hidden /,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
123
153
|
it("renders a partial-failure notice with a Retry action", () => {
|
|
124
154
|
const html = renderToString(
|
|
125
155
|
createElement(SelectionSheet, {
|
|
@@ -66,6 +66,27 @@ export const Expanded: Story = {
|
|
|
66
66
|
args: { startExpanded: true, moveSlot: <MoveSlot /> },
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The same expanded sheet on the shortest phone viewport a real device reports
|
|
71
|
+
* (411×759 — Android Chrome with its address bar and system nav showing). This
|
|
72
|
+
* is the tallest the content ever gets: select-all, all three quick actions and
|
|
73
|
+
* both smart-flow rows. A fixed height ceiling cut "Something else" off here
|
|
74
|
+
* with no way to reach it (#405); the sheet now takes the height its content
|
|
75
|
+
* needs, and scrolls only when the viewport genuinely can't hold it.
|
|
76
|
+
*/
|
|
77
|
+
export const ExpandedShortViewport: Story = {
|
|
78
|
+
globals: { viewport: { value: "mobileShort" } },
|
|
79
|
+
args: {
|
|
80
|
+
startExpanded: true,
|
|
81
|
+
moveSlot: <MoveSlot />,
|
|
82
|
+
selectAll: {
|
|
83
|
+
checked: false,
|
|
84
|
+
indeterminate: true,
|
|
85
|
+
onChange: () => undefined,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
69
90
|
/** While a search result set pages to its total: the count isn't known, the
|
|
70
91
|
* quick actions are replaced by the running total and an explicit Stop. */
|
|
71
92
|
export const Counting: Story = {
|
|
@@ -24,9 +24,13 @@ const formatCount = (n: number): string => n.toLocaleString();
|
|
|
24
24
|
|
|
25
25
|
/** The peek height of the collapsed teaser row (px). */
|
|
26
26
|
export const SELECTION_SHEET_TEASER_HEIGHT = 56;
|
|
27
|
-
/** Ceiling on the expanded sheet height
|
|
28
|
-
*
|
|
29
|
-
|
|
27
|
+
/** Ceiling on the expanded sheet height, as a share of the dynamic viewport.
|
|
28
|
+
* The sheet is otherwise sized by its own content, so the ceiling binds only on
|
|
29
|
+
* a viewport too short to hold the actions — and there they scroll. */
|
|
30
|
+
const EXPANDED_MAX_DVH = 70;
|
|
31
|
+
/** Assumed expanded height until the sheet has been measured; only sets the
|
|
32
|
+
* collapsed translate for the first frame. */
|
|
33
|
+
const EXPANDED_HEIGHT_FALLBACK = 320;
|
|
30
34
|
|
|
31
35
|
const SNAP_MS = 320;
|
|
32
36
|
const SNAP_EASE = "cubic-bezier(0.32, 0.9, 0.3, 1)";
|
|
@@ -131,11 +135,16 @@ export interface SelectionSheetProps {
|
|
|
131
135
|
|
|
132
136
|
/**
|
|
133
137
|
* The mobile multi-select surface: a peeking bottom sheet that teases at ~56px
|
|
134
|
-
* with the selection count, and expands by drag or tap to
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
138
|
+
* with the selection count, and expands by drag or tap to carry the bulk verbs
|
|
139
|
+
* (Delete / Move / Junk), the select-similar → organize entries, and every
|
|
140
|
+
* escalation state (counting, running progress, partial-failure) the selection
|
|
141
|
+
* can be in. Drag or tap the grabber to collapse back to the teaser; the
|
|
142
|
+
* selection is untouched.
|
|
143
|
+
*
|
|
144
|
+
* Expanded, it takes the height its own content needs, up to
|
|
145
|
+
* {@link EXPANDED_MAX_DVH}% of the dynamic viewport, past which the actions
|
|
146
|
+
* scroll. A fixed ceiling sat below the content on a short phone viewport and
|
|
147
|
+
* cut the last action off with no way to reach it (#405).
|
|
139
148
|
*
|
|
140
149
|
* Sits absolutely against the bottom of the nearest positioned ancestor, so the
|
|
141
150
|
* list it belongs to must be a `relative` container and pad its own bottom by
|
|
@@ -160,7 +169,9 @@ export function SelectionSheet({
|
|
|
160
169
|
}: SelectionSheetProps) {
|
|
161
170
|
const [expanded, setExpanded] = useState(startExpanded);
|
|
162
171
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
163
|
-
const [expandedHeight, setExpandedHeight] = useState(
|
|
172
|
+
const [expandedHeight, setExpandedHeight] = useState(
|
|
173
|
+
EXPANDED_HEIGHT_FALLBACK,
|
|
174
|
+
);
|
|
164
175
|
|
|
165
176
|
// A run or a live count owns the sheet: it stays open so the progress and
|
|
166
177
|
// status can't be dragged out of sight mid-operation.
|
|
@@ -265,7 +276,7 @@ export function SelectionSheet({
|
|
|
265
276
|
data-selection-sheet=""
|
|
266
277
|
className="absolute inset-x-0 bottom-0 z-30 flex select-none flex-col rounded-t-2xl border-t border-line bg-surface shadow-2xl shadow-black/40"
|
|
267
278
|
style={{
|
|
268
|
-
maxHeight:
|
|
279
|
+
maxHeight: `${EXPANDED_MAX_DVH}dvh`,
|
|
269
280
|
minHeight: `${SELECTION_SHEET_TEASER_HEIGHT}px`,
|
|
270
281
|
transform: `translateY(${translate}px)`,
|
|
271
282
|
transition,
|
|
@@ -355,10 +366,12 @@ export function SelectionSheet({
|
|
|
355
366
|
|
|
356
367
|
{/* Expanded content — clipped by the translate when collapsed. It stays
|
|
357
368
|
in the DOM at the teaser, so `inert` when collapsed keeps its offscreen
|
|
358
|
-
verbs out of the tab order and the a11y tree until the sheet opens.
|
|
369
|
+
verbs out of the tab order and the a11y tree until the sheet opens.
|
|
370
|
+
Scrolls rather than clips, so every verb stays reachable on a viewport
|
|
371
|
+
the actions don't fit in (#405). */}
|
|
359
372
|
<div
|
|
360
373
|
inert={!expanded ? true : undefined}
|
|
361
|
-
className="flex min-h-0 flex-1 flex-col overflow-
|
|
374
|
+
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4"
|
|
362
375
|
>
|
|
363
376
|
{progress && (
|
|
364
377
|
<div className="mb-3">
|