@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.59",
3
+ "version": "0.0.60",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
- {/* biome-ignore lint/a11y/useSemanticElements: nested <button> inside would be invalid button-in-button */}
272
- <div
273
- role="button"
274
- tabIndex={0}
275
- aria-expanded={open}
276
- aria-label={open ? "Collapse filters" : "Expand filters"}
277
- onClick={() => setOpen(!open)}
278
- onKeyDown={(e) => {
279
- if (!isSelfRowActivation(e)) return;
280
- e.preventDefault();
281
- setOpen(!open);
282
- }}
283
- 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"
284
- >
285
- {summaryChips}
286
- {hasActive && (
287
- <button
288
- type="button"
289
- aria-label="Clear filters"
290
- onClick={(e) => {
291
- e.stopPropagation();
292
- clearAll();
293
- }}
294
- className="flex size-6 items-center justify-center text-fg-subtle hover:text-fg-muted"
295
- >
296
- <Close className="size-3" />
297
- </button>
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
- </div>
306
- {open && (
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
- * External `selectionBar` slot mechanism, exercised at desktop width with
217
- * `SelectionTopBar` as a convenient stand-in node any slot content works
218
- * here, the point is that the pane header is replaced. This is NOT a
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 production composition (`MessageList.tsx:798` gates on `!isDesktop`):
242
- * `SelectionTopBar` in the `selectionBar` slot at narrow width. Desktop never
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
- selectionSheet,
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
- * The mobile multi-select surface: a peeking bottom sheet overlaid on the
90
- * list, distinct from `selectionBar` (which replaces the header at the top).
91
- * Rendered as the last child so it sits above the rows; the caller pads the
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
- selectionSheet?: ReactNode;
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
- {selectionSheet}
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 `items` is empty
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 ref={containerRef} className={cn("relative", className)}>
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
- className={cn(touch && "min-h-11 min-w-11 px-0")}
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>