@remit/ui 0.0.59 → 0.0.61
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/filter-sheet.stories.tsx +14 -0
- package/src/components/filter-sheet.tsx +46 -35
- package/src/components/message-list-pane.stories.tsx +21 -10
- package/src/components/message-list-pane.tsx +7 -7
- package/src/components/popover-menu.render.test.ts +34 -0
- package/src/components/popover-menu.stories.tsx +59 -0
- package/src/components/popover-menu.tsx +38 -7
- package/src/components/selection-top-bar.render.test.ts +206 -73
- package/src/components/selection-top-bar.stories.tsx +152 -53
- package/src/components/selection-top-bar.tsx +241 -73
- package/src/components/selection-wizard.render.test.ts +25 -7
- package/src/components/selection-wizard.tsx +120 -50
- package/src/index.ts +3 -10
- package/src/lib/wizard-steps.test.ts +37 -8
- package/src/lib/wizard-steps.ts +32 -8
- package/src/components/selection-sheet.render.test.ts +0 -169
- package/src/components/selection-sheet.stories.tsx +0 -179
- package/src/components/selection-sheet.test.ts +0 -88
- package/src/components/selection-sheet.tsx +0 -496
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { createElement } from "react";
|
|
4
|
-
import { renderToString } from "react-dom/server";
|
|
5
|
-
import { SelectionSheet, type SelectionSheetProps } from "./selection-sheet.js";
|
|
6
|
-
|
|
7
|
-
const noop = () => {};
|
|
8
|
-
|
|
9
|
-
const base: SelectionSheetProps = {
|
|
10
|
-
count: 3,
|
|
11
|
-
onCancel: noop,
|
|
12
|
-
onDelete: noop,
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
describe("SelectionSheet", () => {
|
|
16
|
-
it("teases collapsed with the count and the swipe hint", () => {
|
|
17
|
-
const html = renderToString(createElement(SelectionSheet, base));
|
|
18
|
-
assert.match(html, /3 messages selected/);
|
|
19
|
-
assert.match(html, /Swipe up for actions/);
|
|
20
|
-
assert.match(html, /role="slider"/);
|
|
21
|
-
// Collapsed: no cancel/mark-read in the header yet.
|
|
22
|
-
assert.doesNotMatch(html, /aria-label="Cancel selection"/);
|
|
23
|
-
// The clipped body is inert while collapsed, so its offscreen verbs stay
|
|
24
|
-
// out of the tab order and the a11y tree.
|
|
25
|
-
assert.match(html, /inert=""/);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("expanded shows the quick actions, cancel, and the smart-flow rows", () => {
|
|
29
|
-
const html = renderToString(
|
|
30
|
-
createElement(SelectionSheet, {
|
|
31
|
-
...base,
|
|
32
|
-
startExpanded: true,
|
|
33
|
-
onMarkRead: noop,
|
|
34
|
-
onJunk: noop,
|
|
35
|
-
onSelectSimilar: noop,
|
|
36
|
-
onSomethingElse: noop,
|
|
37
|
-
moveSlot: createElement("span", null, "move-here"),
|
|
38
|
-
}),
|
|
39
|
-
);
|
|
40
|
-
assert.match(html, /aria-label="Move selected messages to Trash"/);
|
|
41
|
-
assert.match(html, /aria-label="Move selected messages to Junk"/);
|
|
42
|
-
assert.match(html, /aria-label="Mark as read"/);
|
|
43
|
-
assert.match(html, /aria-label="Cancel selection"/);
|
|
44
|
-
assert.match(html, /move-here/);
|
|
45
|
-
assert.match(html, /Select similar messages/);
|
|
46
|
-
assert.match(html, /Something else/);
|
|
47
|
-
// Expanded: the body is live, not inert.
|
|
48
|
-
assert.doesNotMatch(html, /inert=""/);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("names the loaded scope and renders the select-all control", () => {
|
|
52
|
-
const html = renderToString(
|
|
53
|
-
createElement(SelectionSheet, {
|
|
54
|
-
...base,
|
|
55
|
-
count: 47,
|
|
56
|
-
startExpanded: true,
|
|
57
|
-
selectAll: { checked: true, indeterminate: false, onChange: noop },
|
|
58
|
-
}),
|
|
59
|
-
);
|
|
60
|
-
assert.match(html, /All 47 loaded selected/);
|
|
61
|
-
assert.match(html, /aria-label="Select all"/);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("counting replaces the quick actions with the status and a Stop", () => {
|
|
65
|
-
const html = renderToString(
|
|
66
|
-
createElement(SelectionSheet, {
|
|
67
|
-
...base,
|
|
68
|
-
count: 0,
|
|
69
|
-
mode: "counting",
|
|
70
|
-
statusLabel: "Counting… 1,900 so far",
|
|
71
|
-
notice: {
|
|
72
|
-
tone: "info",
|
|
73
|
-
text: "",
|
|
74
|
-
action: { label: "Stop", onClick: noop },
|
|
75
|
-
},
|
|
76
|
-
}),
|
|
77
|
-
);
|
|
78
|
-
assert.match(html, /Counting… 1,900 so far/);
|
|
79
|
-
assert.match(html, /Stop/);
|
|
80
|
-
// No quick actions while counting.
|
|
81
|
-
assert.doesNotMatch(html, /aria-label="Move selected messages to Trash"/);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("running shows a progress bar and no quick actions", () => {
|
|
85
|
-
const html = renderToString(
|
|
86
|
-
createElement(SelectionSheet, {
|
|
87
|
-
...base,
|
|
88
|
-
count: 3412,
|
|
89
|
-
mode: "running",
|
|
90
|
-
isBusy: true,
|
|
91
|
-
statusLabel: "Deleting 1,200 of 3,412…",
|
|
92
|
-
progress: { value: 1200, max: 3412 },
|
|
93
|
-
}),
|
|
94
|
-
);
|
|
95
|
-
assert.match(html, /Deleting 1,200 of 3,412…/);
|
|
96
|
-
assert.match(html, /role="progressbar"/);
|
|
97
|
-
assert.doesNotMatch(html, /Select similar messages/);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("escalated keeps the verbs and a clear-selection notice", () => {
|
|
101
|
-
const html = renderToString(
|
|
102
|
-
createElement(SelectionSheet, {
|
|
103
|
-
...base,
|
|
104
|
-
count: 3412,
|
|
105
|
-
mode: "escalated",
|
|
106
|
-
startExpanded: true,
|
|
107
|
-
statusLabel: 'All 3,412 matching "npm" selected',
|
|
108
|
-
moveSlot: createElement("span", null, "move-here"),
|
|
109
|
-
notice: {
|
|
110
|
-
tone: "info",
|
|
111
|
-
text: "",
|
|
112
|
-
action: { label: "Clear selection", onClick: noop },
|
|
113
|
-
},
|
|
114
|
-
}),
|
|
115
|
-
);
|
|
116
|
-
assert.match(html, /All 3,412 matching/);
|
|
117
|
-
assert.match(html, /aria-label="Move selected messages to Trash"/);
|
|
118
|
-
assert.match(html, /Clear selection/);
|
|
119
|
-
// Not a bounded selection, so no select-similar entry.
|
|
120
|
-
assert.doesNotMatch(html, /Select similar messages/);
|
|
121
|
-
});
|
|
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
|
-
|
|
153
|
-
it("renders a partial-failure notice with a Retry action", () => {
|
|
154
|
-
const html = renderToString(
|
|
155
|
-
createElement(SelectionSheet, {
|
|
156
|
-
...base,
|
|
157
|
-
count: 340,
|
|
158
|
-
startExpanded: true,
|
|
159
|
-
notice: {
|
|
160
|
-
tone: "danger",
|
|
161
|
-
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
162
|
-
action: { label: "Retry 340", onClick: noop },
|
|
163
|
-
},
|
|
164
|
-
}),
|
|
165
|
-
);
|
|
166
|
-
assert.match(html, /3,072 moved to Trash/);
|
|
167
|
-
assert.match(html, /Retry 340/);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { FolderInput } from "lucide-react";
|
|
3
|
-
import { SelectionSheet } from "./selection-sheet.js";
|
|
4
|
-
|
|
5
|
-
const meta: Meta<typeof SelectionSheet> = {
|
|
6
|
-
title: "Screens/Kit/SelectionSheet",
|
|
7
|
-
component: SelectionSheet,
|
|
8
|
-
parameters: { layout: "fullscreen" },
|
|
9
|
-
args: {
|
|
10
|
-
count: 3,
|
|
11
|
-
onCancel: () => undefined,
|
|
12
|
-
onDelete: () => undefined,
|
|
13
|
-
onMarkRead: () => undefined,
|
|
14
|
-
onJunk: () => undefined,
|
|
15
|
-
onSelectSimilar: () => undefined,
|
|
16
|
-
onSomethingElse: () => undefined,
|
|
17
|
-
},
|
|
18
|
-
decorators: [
|
|
19
|
-
(Story) => (
|
|
20
|
-
<div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[720px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
|
|
21
|
-
{/* Inbox backdrop, so the peeking sheet reads against a list. */}
|
|
22
|
-
<div className="divide-y divide-line opacity-50">
|
|
23
|
-
{Array.from({ length: 11 }).map((_, i) => (
|
|
24
|
-
<div
|
|
25
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton rows
|
|
26
|
-
key={i}
|
|
27
|
-
className="flex items-start gap-3 px-row-inset py-2.5"
|
|
28
|
-
>
|
|
29
|
-
<div className="mt-0.5 size-7 shrink-0 rounded-full bg-surface-sunken" />
|
|
30
|
-
<div className="min-w-0 flex-1 space-y-1">
|
|
31
|
-
<div className="h-2.5 w-1/3 rounded bg-surface-sunken" />
|
|
32
|
-
<div className="h-2 w-2/3 rounded bg-surface-sunken" />
|
|
33
|
-
</div>
|
|
34
|
-
</div>
|
|
35
|
-
))}
|
|
36
|
-
</div>
|
|
37
|
-
<Story />
|
|
38
|
-
</div>
|
|
39
|
-
),
|
|
40
|
-
],
|
|
41
|
-
};
|
|
42
|
-
export default meta;
|
|
43
|
-
|
|
44
|
-
type Story = StoryObj<typeof SelectionSheet>;
|
|
45
|
-
|
|
46
|
-
/** Stand-in for the caller's move-to-folder trigger (an icon button that opens
|
|
47
|
-
* a folder picker). The sheet only reserves the slot. */
|
|
48
|
-
const MoveSlot = () => (
|
|
49
|
-
<button
|
|
50
|
-
type="button"
|
|
51
|
-
aria-label="Move selected messages"
|
|
52
|
-
className="inline-flex size-11 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-raised"
|
|
53
|
-
>
|
|
54
|
-
<FolderInput className="size-4" />
|
|
55
|
-
</button>
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
/** Collapsed — the slim ~56px teaser that rises at 2+ selected. */
|
|
59
|
-
export const Teaser: Story = {
|
|
60
|
-
args: { moveSlot: <MoveSlot /> },
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
/** Expanded — quick actions (Delete / Move / Junk) plus the select-similar and
|
|
64
|
-
* "Something else" entries. */
|
|
65
|
-
export const Expanded: Story = {
|
|
66
|
-
args: { startExpanded: true, moveSlot: <MoveSlot /> },
|
|
67
|
-
};
|
|
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
|
-
|
|
90
|
-
/** While a search result set pages to its total: the count isn't known, the
|
|
91
|
-
* quick actions are replaced by the running total and an explicit Stop. */
|
|
92
|
-
export const Counting: Story = {
|
|
93
|
-
args: {
|
|
94
|
-
count: 0,
|
|
95
|
-
mode: "counting",
|
|
96
|
-
startExpanded: true,
|
|
97
|
-
statusLabel: "Counting… 1,900 so far",
|
|
98
|
-
selectAll: {
|
|
99
|
-
checked: true,
|
|
100
|
-
indeterminate: false,
|
|
101
|
-
onChange: () => undefined,
|
|
102
|
-
},
|
|
103
|
-
notice: {
|
|
104
|
-
tone: "info",
|
|
105
|
-
text: "",
|
|
106
|
-
action: { label: "Stop", onClick: () => undefined },
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/** A bulk delete in progress — a running total and a determinate progress bar,
|
|
112
|
-
* the delete busy, no quick actions to act mid-run. */
|
|
113
|
-
export const RunningProgress: Story = {
|
|
114
|
-
args: {
|
|
115
|
-
count: 3412,
|
|
116
|
-
mode: "running",
|
|
117
|
-
startExpanded: true,
|
|
118
|
-
isBusy: true,
|
|
119
|
-
statusLabel: "Deleting 1,200 of 3,412…",
|
|
120
|
-
progress: { value: 1200, max: 3412 },
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Every loaded row checked and the search has more matches: the sheet offers to
|
|
126
|
-
* escalate the selection to the whole result set.
|
|
127
|
-
*/
|
|
128
|
-
export const EscalationAvailable: Story = {
|
|
129
|
-
args: {
|
|
130
|
-
count: 47,
|
|
131
|
-
startExpanded: true,
|
|
132
|
-
moveSlot: <MoveSlot />,
|
|
133
|
-
selectAll: {
|
|
134
|
-
checked: true,
|
|
135
|
-
indeterminate: false,
|
|
136
|
-
onChange: () => undefined,
|
|
137
|
-
},
|
|
138
|
-
notice: {
|
|
139
|
-
tone: "info",
|
|
140
|
-
text: "",
|
|
141
|
-
action: {
|
|
142
|
-
label: 'Select all matching "npm"',
|
|
143
|
-
onClick: () => undefined,
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
/** Selection escalated to the search predicate: the count names the query's
|
|
150
|
-
* total, every verb still acts, and the notice offers a way back. */
|
|
151
|
-
export const Escalated: Story = {
|
|
152
|
-
args: {
|
|
153
|
-
count: 3412,
|
|
154
|
-
mode: "escalated",
|
|
155
|
-
startExpanded: true,
|
|
156
|
-
moveSlot: <MoveSlot />,
|
|
157
|
-
statusLabel: 'All 3,412 matching "npm" selected',
|
|
158
|
-
notice: {
|
|
159
|
-
tone: "info",
|
|
160
|
-
text: "",
|
|
161
|
-
action: { label: "Clear selection", onClick: () => undefined },
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
/** After a bulk delete with some batches failed: the count reflects only what
|
|
167
|
-
* is still selected — the failures — and Retry names how many. */
|
|
168
|
-
export const PartialFailure: Story = {
|
|
169
|
-
args: {
|
|
170
|
-
count: 340,
|
|
171
|
-
startExpanded: true,
|
|
172
|
-
moveSlot: <MoveSlot />,
|
|
173
|
-
notice: {
|
|
174
|
-
tone: "danger",
|
|
175
|
-
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
176
|
-
action: { label: "Retry 340", onClick: () => undefined },
|
|
177
|
-
},
|
|
178
|
-
},
|
|
179
|
-
};
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { resolveSheetSnap } from "./selection-sheet.js";
|
|
4
|
-
|
|
5
|
-
const base = {
|
|
6
|
-
expandedHeight: 320,
|
|
7
|
-
teaserHeight: 56,
|
|
8
|
-
flickVelocity: 0.5,
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
describe("resolveSheetSnap", () => {
|
|
12
|
-
it("flicks up expand regardless of how far the drag travelled", () => {
|
|
13
|
-
assert.equal(
|
|
14
|
-
resolveSheetSnap({ ...base, expanded: false, delta: -4, velocity: -1.2 }),
|
|
15
|
-
true,
|
|
16
|
-
);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it("flicks down collapse regardless of how far the drag travelled", () => {
|
|
20
|
-
assert.equal(
|
|
21
|
-
resolveSheetSnap({ ...base, expanded: true, delta: 4, velocity: 1.2 }),
|
|
22
|
-
false,
|
|
23
|
-
);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("a slow upward drag past the midpoint expands from the teaser", () => {
|
|
27
|
-
// midpoint = (320 - 56) / 2 = 132; -140 crosses it going up.
|
|
28
|
-
assert.equal(
|
|
29
|
-
resolveSheetSnap({
|
|
30
|
-
...base,
|
|
31
|
-
expanded: false,
|
|
32
|
-
delta: -140,
|
|
33
|
-
velocity: 0.1,
|
|
34
|
-
}),
|
|
35
|
-
true,
|
|
36
|
-
);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("a slow upward drag short of the midpoint settles back to the teaser", () => {
|
|
40
|
-
assert.equal(
|
|
41
|
-
resolveSheetSnap({
|
|
42
|
-
...base,
|
|
43
|
-
expanded: false,
|
|
44
|
-
delta: -100,
|
|
45
|
-
velocity: 0.1,
|
|
46
|
-
}),
|
|
47
|
-
false,
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("a slow downward drag past the midpoint collapses from expanded", () => {
|
|
52
|
-
assert.equal(
|
|
53
|
-
resolveSheetSnap({ ...base, expanded: true, delta: 140, velocity: 0.1 }),
|
|
54
|
-
false,
|
|
55
|
-
);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("a slow downward drag short of the midpoint stays expanded", () => {
|
|
59
|
-
assert.equal(
|
|
60
|
-
resolveSheetSnap({ ...base, expanded: true, delta: 100, velocity: 0.1 }),
|
|
61
|
-
true,
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("a barely-moved release keeps the current snap state", () => {
|
|
66
|
-
assert.equal(
|
|
67
|
-
resolveSheetSnap({ ...base, expanded: true, delta: 0, velocity: 0 }),
|
|
68
|
-
true,
|
|
69
|
-
);
|
|
70
|
-
assert.equal(
|
|
71
|
-
resolveSheetSnap({ ...base, expanded: false, delta: 0, velocity: 0 }),
|
|
72
|
-
false,
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("defaults the flick threshold when omitted", () => {
|
|
77
|
-
assert.equal(
|
|
78
|
-
resolveSheetSnap({
|
|
79
|
-
expanded: false,
|
|
80
|
-
delta: -4,
|
|
81
|
-
velocity: -1,
|
|
82
|
-
expandedHeight: 320,
|
|
83
|
-
teaserHeight: 56,
|
|
84
|
-
}),
|
|
85
|
-
true,
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
});
|