@remit/ui 0.0.58 → 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-rule.ts +43 -20
- 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/components/selection-wizard.render.test.ts +725 -0
- package/src/components/selection-wizard.tsx +1078 -0
- package/src/index.ts +57 -0
- package/src/lib/rule-name.test.ts +28 -0
- package/src/lib/rule-name.ts +27 -0
- package/src/lib/sender-derivation.test.ts +53 -0
- package/src/lib/sender-derivation.ts +35 -0
- package/src/lib/wizard-steps.test.ts +673 -0
- package/src/lib/wizard-steps.ts +478 -0
package/package.json
CHANGED
|
@@ -241,15 +241,47 @@ export function commitLabel(scope: RuleScope): string {
|
|
|
241
241
|
return "Save until then";
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Why a rule cannot be committed yet, one message per unmet condition. Any
|
|
246
|
+
* surface that asks for a rule — the editor in one pass, the wizard one step at
|
|
247
|
+
* a time — says the same thing about the same gap, because there is one string
|
|
248
|
+
* for it.
|
|
249
|
+
*/
|
|
250
|
+
export const ruleBlockedCopy = {
|
|
251
|
+
noMatch: "Add a clause so the rule has something to match.",
|
|
252
|
+
// A one-time apply runs the vector-free matcher, which cannot read message
|
|
253
|
+
// bodies. Saved as a rule the same clause works, so say that rather than
|
|
254
|
+
// refusing the clause outright.
|
|
255
|
+
bodyTextOnce:
|
|
256
|
+
"Applying once can't read message bodies. Save this as a rule instead, or drop the “has the words” clause.",
|
|
257
|
+
noAction: "Pick a folder to move into, or a label to apply.",
|
|
258
|
+
unnamed: "Name this rule so you can find it later.",
|
|
259
|
+
noUntilDate: "Pick the date this rule should stop on.",
|
|
260
|
+
counting: "Counting matches — save once the count settles.",
|
|
261
|
+
recounting: "Recounting matches — save once the count settles.",
|
|
262
|
+
} as const;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Why the count on screen is not yet the count that will be applied, or
|
|
266
|
+
* `undefined` once it has settled. This is what makes RFC 038's
|
|
267
|
+
* previewed-set-equals-applied-set contract structural rather than a promise:
|
|
268
|
+
* no surface can commit a rule whose match count is still moving.
|
|
269
|
+
*/
|
|
270
|
+
export function previewSettledReason(
|
|
271
|
+
preview: PreviewCount,
|
|
272
|
+
): string | undefined {
|
|
273
|
+
if (preview.status === "loading") return ruleBlockedCopy.counting;
|
|
274
|
+
if (preview.status === "ready" && preview.stale)
|
|
275
|
+
return ruleBlockedCopy.recounting;
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
244
279
|
/**
|
|
245
280
|
* Why the rule cannot be saved yet, or `undefined` when it is ready. A rule
|
|
246
281
|
* needs at least one live way to match, at least one action — move and/or
|
|
247
282
|
* label, either satisfies this (issue #26) — and, for the two persisted
|
|
248
|
-
* scopes, a name and, for `until`, a date. It also needs a settled preview
|
|
249
|
-
*
|
|
250
|
-
* will be applied. That makes RFC 038's previewed-set-equals-applied-set
|
|
251
|
-
* contract structural — a consumer cannot save a rule whose match count is
|
|
252
|
-
* still moving. Never disable a control without saying why (ux.md).
|
|
283
|
+
* scopes, a name and, for `until`, a date. It also needs a settled preview.
|
|
284
|
+
* Never disable a control without saying why (ux.md).
|
|
253
285
|
*/
|
|
254
286
|
export function commitBlockedReason(
|
|
255
287
|
rule: FilterRule,
|
|
@@ -258,26 +290,17 @@ export function commitBlockedReason(
|
|
|
258
290
|
const hasMatch =
|
|
259
291
|
rule.clauses.length > 0 ||
|
|
260
292
|
(rule.widen !== undefined && !rule.widen.inactive);
|
|
261
|
-
if (!hasMatch) return
|
|
262
|
-
// A one-time apply runs the vector-free matcher, which cannot read message
|
|
263
|
-
// bodies. Saved as a rule the same clause works, so say that rather than
|
|
264
|
-
// refusing the clause outright.
|
|
293
|
+
if (!hasMatch) return ruleBlockedCopy.noMatch;
|
|
265
294
|
if (rule.scope === "once" && unreadableBodyClauses(rule).length > 0)
|
|
266
|
-
return
|
|
267
|
-
if (!rule.moveMailboxId && !rule.labelId)
|
|
268
|
-
return "Pick a folder to move into, or a label to apply.";
|
|
295
|
+
return ruleBlockedCopy.bodyTextOnce;
|
|
296
|
+
if (!rule.moveMailboxId && !rule.labelId) return ruleBlockedCopy.noAction;
|
|
269
297
|
if (
|
|
270
298
|
(rule.scope === "standing" || rule.scope === "until") &&
|
|
271
299
|
(rule.name ?? "").trim() === ""
|
|
272
300
|
)
|
|
273
|
-
return
|
|
274
|
-
if (rule.scope === "until" && !rule.until)
|
|
275
|
-
|
|
276
|
-
if (preview.status === "loading")
|
|
277
|
-
return "Counting matches — save once the count settles.";
|
|
278
|
-
if (preview.status === "ready" && preview.stale)
|
|
279
|
-
return "Recounting matches — save once the count settles.";
|
|
280
|
-
return undefined;
|
|
301
|
+
return ruleBlockedCopy.unnamed;
|
|
302
|
+
if (rule.scope === "until" && !rule.until) return ruleBlockedCopy.noUntilDate;
|
|
303
|
+
return previewSettledReason(preview);
|
|
281
304
|
}
|
|
282
305
|
|
|
283
306
|
export const demoFolders: FolderOption[] = [
|
|
@@ -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>
|