@remit/ui 0.0.59 → 0.0.60
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 +208 -73
- package/src/components/selection-top-bar.stories.tsx +156 -42
- package/src/components/selection-top-bar.tsx +237 -72
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -62,10 +62,12 @@ function ControlledShell({
|
|
|
62
62
|
withSources = true,
|
|
63
63
|
singleAccount = false,
|
|
64
64
|
sourcesNote,
|
|
65
|
+
hideChrome = false,
|
|
65
66
|
}: {
|
|
66
67
|
initialCategory?: string;
|
|
67
68
|
initialFilters?: Set<string>;
|
|
68
69
|
initialExpanded?: boolean;
|
|
70
|
+
hideChrome?: boolean;
|
|
69
71
|
initialSource?: string;
|
|
70
72
|
withSources?: boolean;
|
|
71
73
|
singleAccount?: boolean;
|
|
@@ -85,6 +87,7 @@ function ControlledShell({
|
|
|
85
87
|
return (
|
|
86
88
|
<div className="h-[600px] w-[390px]">
|
|
87
89
|
<FilterSheet
|
|
90
|
+
hideChrome={hideChrome}
|
|
88
91
|
categories={CATEGORIES}
|
|
89
92
|
filters={FILTERS}
|
|
90
93
|
sources={sources}
|
|
@@ -156,3 +159,14 @@ export const CollapsedWithSourceSelected: Story = {
|
|
|
156
159
|
export const SingleAccount: Story = {
|
|
157
160
|
render: () => <ControlledShell singleAccount />,
|
|
158
161
|
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The same shell with the filter row dropped, which is what a live search does:
|
|
165
|
+
* a query narrows the same list by the same intent, so the two never share the
|
|
166
|
+
* row. The children stay exactly where they were — swapping their parent for a
|
|
167
|
+
* plain container instead would remount everything under it, including a search
|
|
168
|
+
* field being typed into.
|
|
169
|
+
*/
|
|
170
|
+
export const ChromeHidden: Story = {
|
|
171
|
+
render: () => <ControlledShell hideChrome />,
|
|
172
|
+
};
|
|
@@ -62,6 +62,14 @@ export interface FilterSheetProps {
|
|
|
62
62
|
* Required when `expanded` is a controlled prop.
|
|
63
63
|
*/
|
|
64
64
|
onExpandedChange?: (expanded: boolean) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Drop the filter row and its panel, keeping the shell and the children
|
|
67
|
+
* exactly where they are. A search narrows the same list by the same intent
|
|
68
|
+
* and takes the filter's place, and swapping the children's parent for a
|
|
69
|
+
* plain container instead would remount everything under it — including a
|
|
70
|
+
* search field being typed into.
|
|
71
|
+
*/
|
|
72
|
+
hideChrome?: boolean;
|
|
65
73
|
/**
|
|
66
74
|
* The list rendered below the filter. The expanded panel is inline: it pushes
|
|
67
75
|
* this content down (the list reflows), it does not overlay it.
|
|
@@ -112,6 +120,7 @@ export function FilterSheet({
|
|
|
112
120
|
onToggleFilter,
|
|
113
121
|
onClear,
|
|
114
122
|
onExpandedChange,
|
|
123
|
+
hideChrome = false,
|
|
115
124
|
children,
|
|
116
125
|
}: FilterSheetProps) {
|
|
117
126
|
const isControlled = expandedProp !== undefined;
|
|
@@ -268,42 +277,44 @@ export function FilterSheet({
|
|
|
268
277
|
{/* The toggle is a div-button (not a real <button>) so the Clear
|
|
269
278
|
control can be a real nested <button> without invalid
|
|
270
279
|
button-in-button nesting. */}
|
|
271
|
-
{
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
e
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
<ChevronDown
|
|
300
|
-
className={cn(
|
|
301
|
-
"ml-auto size-3 shrink-0 text-fg-subtle transition-transform duration-200",
|
|
302
|
-
open ? "rotate-180" : "rotate-0",
|
|
280
|
+
{!hideChrome && (
|
|
281
|
+
// biome-ignore lint/a11y/useSemanticElements: nested <button> inside would be invalid button-in-button
|
|
282
|
+
<div
|
|
283
|
+
role="button"
|
|
284
|
+
tabIndex={0}
|
|
285
|
+
aria-expanded={open}
|
|
286
|
+
aria-label={open ? "Collapse filters" : "Expand filters"}
|
|
287
|
+
onClick={() => setOpen(!open)}
|
|
288
|
+
onKeyDown={(e) => {
|
|
289
|
+
if (!isSelfRowActivation(e)) return;
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
setOpen(!open);
|
|
292
|
+
}}
|
|
293
|
+
className="flex h-section-row w-full shrink-0 cursor-pointer items-center gap-1.5 overflow-x-hidden border-b border-line bg-surface-sunken px-row-inset text-left hover:bg-surface"
|
|
294
|
+
>
|
|
295
|
+
{summaryChips}
|
|
296
|
+
{hasActive && (
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
aria-label="Clear filters"
|
|
300
|
+
onClick={(e) => {
|
|
301
|
+
e.stopPropagation();
|
|
302
|
+
clearAll();
|
|
303
|
+
}}
|
|
304
|
+
className="flex size-6 items-center justify-center text-fg-subtle hover:text-fg-muted"
|
|
305
|
+
>
|
|
306
|
+
<Close className="size-3" />
|
|
307
|
+
</button>
|
|
303
308
|
)}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
309
|
+
<ChevronDown
|
|
310
|
+
className={cn(
|
|
311
|
+
"ml-auto size-3 shrink-0 text-fg-subtle transition-transform duration-200",
|
|
312
|
+
open ? "rotate-180" : "rotate-0",
|
|
313
|
+
)}
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
{open && !hideChrome && (
|
|
307
318
|
<div className="shrink-0">
|
|
308
319
|
{sourceRow}
|
|
309
320
|
{categoryRow}
|
|
@@ -213,13 +213,9 @@ export const CustomListBody: Story = {
|
|
|
213
213
|
};
|
|
214
214
|
|
|
215
215
|
/**
|
|
216
|
-
*
|
|
217
|
-
* `SelectionTopBar`
|
|
218
|
-
*
|
|
219
|
-
* production composition: the live desktop toolbar is `SelectionToolbar`
|
|
220
|
-
* (web-client only, not in this kit); `MessageList.tsx` only ever puts
|
|
221
|
-
* `SelectionTopBar` in this slot when `!isDesktop` — see `NarrowExternalSelectionBar`
|
|
222
|
-
* below for that production-accurate case.
|
|
216
|
+
* The `selectionBar` slot at desktop width, holding the same
|
|
217
|
+
* `SelectionTopBar` the narrow story below holds. One surface at every
|
|
218
|
+
* width: from 768px up the labelled select-all sits inline in the bar.
|
|
223
219
|
*/
|
|
224
220
|
export const ExternalSelectionBar: Story = {
|
|
225
221
|
args: {
|
|
@@ -227,10 +223,18 @@ export const ExternalSelectionBar: Story = {
|
|
|
227
223
|
flatList: true,
|
|
228
224
|
selectionBar: (
|
|
229
225
|
<SelectionTopBar
|
|
226
|
+
title="Inbox"
|
|
230
227
|
count={2}
|
|
231
228
|
onCancel={() => undefined}
|
|
232
229
|
onMarkRead={() => undefined}
|
|
230
|
+
onJunk={() => undefined}
|
|
231
|
+
onOrganize={() => undefined}
|
|
233
232
|
onDelete={() => undefined}
|
|
233
|
+
selectAll={{
|
|
234
|
+
checked: false,
|
|
235
|
+
indeterminate: true,
|
|
236
|
+
onChange: () => undefined,
|
|
237
|
+
}}
|
|
234
238
|
/>
|
|
235
239
|
),
|
|
236
240
|
},
|
|
@@ -238,9 +242,8 @@ export const ExternalSelectionBar: Story = {
|
|
|
238
242
|
};
|
|
239
243
|
|
|
240
244
|
/**
|
|
241
|
-
* The
|
|
242
|
-
*
|
|
243
|
-
* renders this component in the slot — only `NarrowTouchList`'s width does.
|
|
245
|
+
* The same bar at phone width: select-all moves to a second row so row one
|
|
246
|
+
* stays a count and the verbs, with a back arrow out of selection.
|
|
244
247
|
*/
|
|
245
248
|
export const NarrowExternalSelectionBar: Story = {
|
|
246
249
|
args: {
|
|
@@ -248,10 +251,18 @@ export const NarrowExternalSelectionBar: Story = {
|
|
|
248
251
|
flatList: true,
|
|
249
252
|
selectionBar: (
|
|
250
253
|
<SelectionTopBar
|
|
254
|
+
title="Inbox"
|
|
251
255
|
count={2}
|
|
252
256
|
onCancel={() => undefined}
|
|
253
257
|
onMarkRead={() => undefined}
|
|
258
|
+
onJunk={() => undefined}
|
|
259
|
+
onOrganize={() => undefined}
|
|
254
260
|
onDelete={() => undefined}
|
|
261
|
+
selectAll={{
|
|
262
|
+
checked: false,
|
|
263
|
+
indeterminate: true,
|
|
264
|
+
onChange: () => undefined,
|
|
265
|
+
}}
|
|
255
266
|
/>
|
|
256
267
|
),
|
|
257
268
|
},
|
|
@@ -43,7 +43,7 @@ export function MessageListPane({
|
|
|
43
43
|
isDesktop,
|
|
44
44
|
initialTouchState,
|
|
45
45
|
selectionBar,
|
|
46
|
-
|
|
46
|
+
paneOverlay,
|
|
47
47
|
listBody,
|
|
48
48
|
hideHeader = false,
|
|
49
49
|
}: Pick<
|
|
@@ -86,12 +86,11 @@ export function MessageListPane({
|
|
|
86
86
|
*/
|
|
87
87
|
selectionBar?: ReactNode;
|
|
88
88
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* list's own bottom so no row hides behind the teaser.
|
|
89
|
+
* An overlay covering the pane, above the rows — the guided organize flow.
|
|
90
|
+
* The pane is the positioned ancestor it measures against, and it is
|
|
91
|
+
* rendered last so it sits over everything the pane draws.
|
|
93
92
|
*/
|
|
94
|
-
|
|
93
|
+
paneOverlay?: ReactNode;
|
|
95
94
|
/**
|
|
96
95
|
* Overrides the row-rendering section of the non-brief list — the whole
|
|
97
96
|
* scrollable body including virtualization, swipe-triage and any load-more
|
|
@@ -166,6 +165,7 @@ export function MessageListPane({
|
|
|
166
165
|
{selectionBar ??
|
|
167
166
|
(inBuiltinSelection ? (
|
|
168
167
|
<SelectionTopBar
|
|
168
|
+
title={listTitle}
|
|
169
169
|
count={checkedIds.size}
|
|
170
170
|
onCancel={cancelSelection}
|
|
171
171
|
onMarkRead={cancelSelection}
|
|
@@ -273,7 +273,7 @@ export function MessageListPane({
|
|
|
273
273
|
)}
|
|
274
274
|
|
|
275
275
|
{isDesktop && <KeyboardHintBar />}
|
|
276
|
-
{
|
|
276
|
+
{paneOverlay}
|
|
277
277
|
</section>
|
|
278
278
|
);
|
|
279
279
|
}
|
|
@@ -29,4 +29,38 @@ describe("PopoverMenu", () => {
|
|
|
29
29
|
);
|
|
30
30
|
assert.equal(html, "");
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
it("stays alive for children alone, so a nested picker is reachable", () => {
|
|
34
|
+
const html = renderToString(
|
|
35
|
+
createElement(
|
|
36
|
+
PopoverMenu,
|
|
37
|
+
{ triggerLabel: "More actions", items: [] },
|
|
38
|
+
createElement("span", null, "Apply label"),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
assert.match(html, /aria-label="More actions"/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("makes a nested menu a menuitem of the menu it sits in", () => {
|
|
45
|
+
const html = renderToString(
|
|
46
|
+
createElement(PopoverMenu, {
|
|
47
|
+
triggerLabel: "Apply label to selected messages",
|
|
48
|
+
items: [item],
|
|
49
|
+
nested: true,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
assert.match(html, /aria-label="Apply label[^>]*role="menuitem"/);
|
|
53
|
+
assert.match(html, /role="none"/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("renders the trigger text beside its glyph", () => {
|
|
57
|
+
const html = renderToString(
|
|
58
|
+
createElement(PopoverMenu, {
|
|
59
|
+
triggerLabel: "Apply label to selected messages",
|
|
60
|
+
triggerText: "Apply label",
|
|
61
|
+
items: [item],
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
assert.match(html, /Apply label</);
|
|
65
|
+
});
|
|
32
66
|
});
|
|
@@ -55,3 +55,62 @@ export const SingleItem: Story = {
|
|
|
55
55
|
export const Empty: Story = {
|
|
56
56
|
args: { triggerLabel: "More actions", items: [] },
|
|
57
57
|
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A nested picker at the foot of the menu, for a list that belongs to the
|
|
61
|
+
* account rather than to the bar — the selection bar's apply-label trigger.
|
|
62
|
+
* Its own trigger is a worded row, so it reads as one of the menu's actions
|
|
63
|
+
* rather than a stray glyph.
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* A list longer than the viewport. The panel scrolls within its own bounds, so
|
|
67
|
+
* the last row is reachable on a phone instead of running off the bottom.
|
|
68
|
+
*/
|
|
69
|
+
export const ManyItems: Story = {
|
|
70
|
+
args: {
|
|
71
|
+
triggerLabel: "More actions",
|
|
72
|
+
items: Array.from({ length: 24 }, (_, i) => ({
|
|
73
|
+
key: `label-${i}`,
|
|
74
|
+
label: `Label ${i + 1}`,
|
|
75
|
+
icon: <Tag className="size-4" />,
|
|
76
|
+
onSelect: () => undefined,
|
|
77
|
+
})),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const WithNestedPicker: Story = {
|
|
82
|
+
args: {
|
|
83
|
+
triggerLabel: "More actions",
|
|
84
|
+
items: [
|
|
85
|
+
{
|
|
86
|
+
key: "read",
|
|
87
|
+
label: "Mark read",
|
|
88
|
+
icon: <MailOpen className="size-4" />,
|
|
89
|
+
onSelect: () => undefined,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
children: (
|
|
93
|
+
<PopoverMenu
|
|
94
|
+
triggerLabel="Apply label to selected messages"
|
|
95
|
+
triggerIcon={<Tag className="size-4 text-fg-subtle" />}
|
|
96
|
+
triggerText="Apply label"
|
|
97
|
+
align="start"
|
|
98
|
+
nested
|
|
99
|
+
touch={false}
|
|
100
|
+
triggerClassName="min-h-11 w-full justify-start gap-3 px-4 py-2.5 text-sm font-normal text-fg"
|
|
101
|
+
items={[
|
|
102
|
+
{
|
|
103
|
+
key: "work",
|
|
104
|
+
label: "Work",
|
|
105
|
+
onSelect: () => undefined,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: "receipts",
|
|
109
|
+
label: "Receipts",
|
|
110
|
+
onSelect: () => undefined,
|
|
111
|
+
},
|
|
112
|
+
]}
|
|
113
|
+
/>
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -16,12 +16,27 @@ export interface PopoverMenuProps {
|
|
|
16
16
|
triggerLabel: string;
|
|
17
17
|
/** Trigger glyph. Defaults to the vertical ellipsis (kebab). */
|
|
18
18
|
triggerIcon?: ReactNode;
|
|
19
|
+
/** Visible trigger text. Without it the trigger is the glyph alone. */
|
|
20
|
+
triggerText?: string;
|
|
19
21
|
items: PopoverMenuItem[];
|
|
20
22
|
/** Which edge the menu aligns to. Defaults to "end" (right). */
|
|
21
23
|
align?: "start" | "end";
|
|
22
24
|
/** Touch-sizes the trigger to ≥44px. Defaults to true. */
|
|
23
25
|
touch?: boolean;
|
|
24
26
|
className?: string;
|
|
27
|
+
triggerClassName?: string;
|
|
28
|
+
/**
|
|
29
|
+
* This menu is itself a row of an enclosing menu: the trigger becomes a
|
|
30
|
+
* `menuitem` and its wrapper drops out of the accessibility tree, so the
|
|
31
|
+
* enclosing `menu` holds only menu children.
|
|
32
|
+
*/
|
|
33
|
+
nested?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Extra rows below the items — a nested trigger whose own list is too long
|
|
36
|
+
* or too dynamic to flatten into `items`, e.g. the label picker. Present
|
|
37
|
+
* children keep the menu alive even with no items of its own.
|
|
38
|
+
*/
|
|
39
|
+
children?: ReactNode;
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
/**
|
|
@@ -29,16 +44,24 @@ export interface PopoverMenuProps {
|
|
|
29
44
|
* dismissed on outside-click or Escape. Built on the kit `Button` for the
|
|
30
45
|
* trigger; rows are ≥44px for touch ergonomics. The home for the secondary
|
|
31
46
|
* actions an overflow menu collects (mark read/unread, …) so the live client
|
|
32
|
-
* stops hand-rolling the same popover. Renders nothing when
|
|
33
|
-
* an empty kebab is dead weight, not a disabled control
|
|
47
|
+
* stops hand-rolling the same popover. Renders nothing when there are no items
|
|
48
|
+
* and no children — an empty kebab is dead weight, not a disabled control, so
|
|
49
|
+
* a caller passing `children` owes it something to show.
|
|
50
|
+
*
|
|
51
|
+
* The panel scrolls within the viewport: a list as long as an account's labels
|
|
52
|
+
* would otherwise run off the bottom of a phone with no way to reach its end.
|
|
34
53
|
*/
|
|
35
54
|
export function PopoverMenu({
|
|
36
55
|
triggerLabel,
|
|
37
56
|
triggerIcon,
|
|
57
|
+
triggerText,
|
|
38
58
|
items,
|
|
39
59
|
align = "end",
|
|
40
60
|
touch = true,
|
|
41
61
|
className,
|
|
62
|
+
triggerClassName,
|
|
63
|
+
nested = false,
|
|
64
|
+
children,
|
|
42
65
|
}: PopoverMenuProps) {
|
|
43
66
|
const [open, setOpen] = useState(false);
|
|
44
67
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -64,10 +87,14 @@ export function PopoverMenu({
|
|
|
64
87
|
};
|
|
65
88
|
}, [open]);
|
|
66
89
|
|
|
67
|
-
if (items.length === 0) return null;
|
|
90
|
+
if (items.length === 0 && !children) return null;
|
|
68
91
|
|
|
69
92
|
return (
|
|
70
|
-
<div
|
|
93
|
+
<div
|
|
94
|
+
ref={containerRef}
|
|
95
|
+
className={cn("relative", className)}
|
|
96
|
+
{...(nested ? { role: "none" } : {})}
|
|
97
|
+
>
|
|
71
98
|
<Button
|
|
72
99
|
variant="ghost"
|
|
73
100
|
size="sm"
|
|
@@ -76,13 +103,16 @@ export function PopoverMenu({
|
|
|
76
103
|
aria-label={triggerLabel}
|
|
77
104
|
aria-haspopup="menu"
|
|
78
105
|
aria-expanded={open}
|
|
79
|
-
|
|
80
|
-
|
|
106
|
+
{...(nested ? { role: "menuitem" } : {})}
|
|
107
|
+
className={cn(touch && "min-h-11 min-w-11 px-0", triggerClassName)}
|
|
108
|
+
>
|
|
109
|
+
{triggerText}
|
|
110
|
+
</Button>
|
|
81
111
|
{open && (
|
|
82
112
|
<div
|
|
83
113
|
role="menu"
|
|
84
114
|
className={cn(
|
|
85
|
-
"absolute top-full z-50 mt-1 flex min-w-44 flex-col rounded-md border border-line bg-surface py-1 shadow-lg",
|
|
115
|
+
"absolute top-full z-50 mt-1 flex max-h-[60dvh] min-w-44 flex-col overflow-y-auto overscroll-contain rounded-md border border-line bg-surface py-1 shadow-lg",
|
|
86
116
|
align === "end" ? "right-0" : "left-0",
|
|
87
117
|
)}
|
|
88
118
|
>
|
|
@@ -103,6 +133,7 @@ export function PopoverMenu({
|
|
|
103
133
|
{item.label}
|
|
104
134
|
</button>
|
|
105
135
|
))}
|
|
136
|
+
{children}
|
|
106
137
|
</div>
|
|
107
138
|
)}
|
|
108
139
|
</div>
|