@remit/ui 0.0.1

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.
Files changed (188) hide show
  1. package/package.json +50 -0
  2. package/src/components/address-display.render.test.ts +93 -0
  3. package/src/components/address-display.stories.tsx +55 -0
  4. package/src/components/address-display.tsx +132 -0
  5. package/src/components/address-tag.render.test.ts +39 -0
  6. package/src/components/address-tag.stories.tsx +41 -0
  7. package/src/components/address-tag.tsx +38 -0
  8. package/src/components/app-password-hint.render.test.ts +23 -0
  9. package/src/components/app-password-hint.stories.tsx +19 -0
  10. package/src/components/app-password-hint.tsx +24 -0
  11. package/src/components/app-shell-slotted.render.test.ts +128 -0
  12. package/src/components/app-shell-slotted.tsx +267 -0
  13. package/src/components/app-shell-types.ts +328 -0
  14. package/src/components/app-shell.render.test.ts +415 -0
  15. package/src/components/app-shell.tsx +277 -0
  16. package/src/components/auth-card.render.test.ts +24 -0
  17. package/src/components/auth-card.stories.tsx +50 -0
  18. package/src/components/auth-card.tsx +32 -0
  19. package/src/components/auth-footer.render.test.ts +17 -0
  20. package/src/components/auth-footer.stories.tsx +23 -0
  21. package/src/components/auth-footer.tsx +14 -0
  22. package/src/components/auth-hero.render.test.ts +31 -0
  23. package/src/components/auth-hero.stories.tsx +19 -0
  24. package/src/components/auth-hero.tsx +41 -0
  25. package/src/components/auto-moved-badge.render.test.ts +42 -0
  26. package/src/components/auto-moved-badge.stories.tsx +48 -0
  27. package/src/components/auto-moved-badge.tsx +57 -0
  28. package/src/components/avatar.tsx +60 -0
  29. package/src/components/badge.tsx +49 -0
  30. package/src/components/banner.render.test.ts +58 -0
  31. package/src/components/banner.stories.tsx +92 -0
  32. package/src/components/banner.tsx +81 -0
  33. package/src/components/bottom-sheet.render.test.ts +48 -0
  34. package/src/components/bottom-sheet.stories.tsx +71 -0
  35. package/src/components/bottom-sheet.tsx +146 -0
  36. package/src/components/brief-section.render.test.ts +98 -0
  37. package/src/components/brief-section.stories.tsx +68 -0
  38. package/src/components/brief-section.tsx +102 -0
  39. package/src/components/brief-sections.render.test.ts +72 -0
  40. package/src/components/brief-sections.stories.tsx +178 -0
  41. package/src/components/brief-sections.tsx +201 -0
  42. package/src/components/button.tsx +48 -0
  43. package/src/components/card.tsx +48 -0
  44. package/src/components/category-badge.render.test.ts +40 -0
  45. package/src/components/category-badge.stories.tsx +37 -0
  46. package/src/components/category-badge.tsx +71 -0
  47. package/src/components/checkbox.render.test.ts +65 -0
  48. package/src/components/checkbox.stories.tsx +77 -0
  49. package/src/components/checkbox.tsx +78 -0
  50. package/src/components/compose-action-bar.render.test.ts +47 -0
  51. package/src/components/compose-action-bar.stories.tsx +35 -0
  52. package/src/components/compose-action-bar.tsx +95 -0
  53. package/src/components/compose-form-shell.render.test.ts +54 -0
  54. package/src/components/compose-form-shell.stories.tsx +96 -0
  55. package/src/components/compose-form-shell.tsx +49 -0
  56. package/src/components/danger-zone-section.tsx +36 -0
  57. package/src/components/dialog.render.test.ts +55 -0
  58. package/src/components/dialog.tsx +104 -0
  59. package/src/components/email-frame-css.ts +137 -0
  60. package/src/components/field-label.render.test.ts +16 -0
  61. package/src/components/field-label.tsx +19 -0
  62. package/src/components/filter-sheet.render.test.ts +76 -0
  63. package/src/components/filter-sheet.stories.tsx +146 -0
  64. package/src/components/filter-sheet.tsx +316 -0
  65. package/src/components/folder-role.tsx +66 -0
  66. package/src/components/input.tsx +44 -0
  67. package/src/components/intelligence-panel.stories.tsx +149 -0
  68. package/src/components/intelligence-panel.tsx +546 -0
  69. package/src/components/isolated-email-frame.render.test.ts +147 -0
  70. package/src/components/isolated-email-frame.stories.tsx +192 -0
  71. package/src/components/isolated-email-frame.tsx +265 -0
  72. package/src/components/kbd.tsx +17 -0
  73. package/src/components/keyboard-hint-bar.tsx +58 -0
  74. package/src/components/list-item.tsx +39 -0
  75. package/src/components/mail-action-toolbar.render.test.ts +43 -0
  76. package/src/components/mail-action-toolbar.tsx +180 -0
  77. package/src/components/mail-header.render.test.ts +69 -0
  78. package/src/components/mail-header.stories.tsx +233 -0
  79. package/src/components/mail-header.tsx +123 -0
  80. package/src/components/message-body-view.render.test.ts +59 -0
  81. package/src/components/message-body-view.stories.tsx +146 -0
  82. package/src/components/message-body-view.tsx +150 -0
  83. package/src/components/message-header.render.test.ts +58 -0
  84. package/src/components/message-header.stories.tsx +79 -0
  85. package/src/components/message-header.tsx +59 -0
  86. package/src/components/message-list-pane.render.test.ts +136 -0
  87. package/src/components/message-list-pane.stories.tsx +246 -0
  88. package/src/components/message-list-pane.tsx +248 -0
  89. package/src/components/message-list-state.render.test.ts +72 -0
  90. package/src/components/message-list-state.tsx +97 -0
  91. package/src/components/message-row.render.test.ts +48 -0
  92. package/src/components/message-row.stories.tsx +121 -0
  93. package/src/components/message-row.tsx +231 -0
  94. package/src/components/mobile-message-action-bar.render.test.ts +87 -0
  95. package/src/components/mobile-message-action-bar.stories.tsx +57 -0
  96. package/src/components/mobile-message-action-bar.tsx +175 -0
  97. package/src/components/mobile-reading-pane.render.test.ts +102 -0
  98. package/src/components/mobile-reading-pane.tsx +188 -0
  99. package/src/components/mobile-search-view.stories.tsx +247 -0
  100. package/src/components/mobile-search-view.tsx +100 -0
  101. package/src/components/move-mailbox-picker.render.test.ts +113 -0
  102. package/src/components/move-mailbox-picker.stories.tsx +76 -0
  103. package/src/components/move-mailbox-picker.tsx +324 -0
  104. package/src/components/nav-sidebar.render.test.ts +351 -0
  105. package/src/components/nav-sidebar.stories.tsx +216 -0
  106. package/src/components/nav-sidebar.tsx +625 -0
  107. package/src/components/outbox-row.render.test.ts +61 -0
  108. package/src/components/outbox-row.stories.tsx +53 -0
  109. package/src/components/outbox-row.tsx +120 -0
  110. package/src/components/outbox-status-badge.tsx +60 -0
  111. package/src/components/pane-layout.render.test.ts +105 -0
  112. package/src/components/popover-menu.render.test.ts +32 -0
  113. package/src/components/popover-menu.stories.tsx +57 -0
  114. package/src/components/popover-menu.tsx +110 -0
  115. package/src/components/primitives.stories.tsx +173 -0
  116. package/src/components/pull-to-refresh.render.test.ts +46 -0
  117. package/src/components/pull-to-refresh.stories.tsx +71 -0
  118. package/src/components/pull-to-refresh.tsx +54 -0
  119. package/src/components/quoted-text.render.test.ts +39 -0
  120. package/src/components/quoted-text.stories.tsx +67 -0
  121. package/src/components/quoted-text.tsx +67 -0
  122. package/src/components/reading-pane-empty.tsx +44 -0
  123. package/src/components/reading-pane.render.test.ts +148 -0
  124. package/src/components/reading-pane.stories.tsx +194 -0
  125. package/src/components/reading-pane.tsx +351 -0
  126. package/src/components/rescue-banner.render.test.ts +32 -0
  127. package/src/components/rescue-banner.stories.tsx +31 -0
  128. package/src/components/rescue-banner.tsx +50 -0
  129. package/src/components/rescue-candidate-row.render.test.ts +68 -0
  130. package/src/components/rescue-candidate-row.stories.tsx +108 -0
  131. package/src/components/rescue-candidate-row.tsx +94 -0
  132. package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
  133. package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
  134. package/src/components/rescue-from-spam-flow.tsx +342 -0
  135. package/src/components/resizable.tsx +55 -0
  136. package/src/components/role-appointment-list.render.test.ts +97 -0
  137. package/src/components/role-appointment-list.stories.tsx +138 -0
  138. package/src/components/role-appointment-list.tsx +227 -0
  139. package/src/components/row-actions.render.test.ts +83 -0
  140. package/src/components/row-actions.stories.tsx +87 -0
  141. package/src/components/row-actions.tsx +139 -0
  142. package/src/components/search-bar.render.test.ts +55 -0
  143. package/src/components/search-bar.stories.tsx +46 -0
  144. package/src/components/search-bar.tsx +118 -0
  145. package/src/components/search-result-row.tsx +139 -0
  146. package/src/components/search-results.render.test.ts +89 -0
  147. package/src/components/search-results.stories.tsx +126 -0
  148. package/src/components/search-results.tsx +215 -0
  149. package/src/components/search-token-chip.render.test.ts +42 -0
  150. package/src/components/search-token-chip.stories.tsx +42 -0
  151. package/src/components/search-token-chip.tsx +66 -0
  152. package/src/components/security-select.render.test.ts +33 -0
  153. package/src/components/security-select.tsx +53 -0
  154. package/src/components/segmented-control.tsx +70 -0
  155. package/src/components/select.tsx +33 -0
  156. package/src/components/selection-top-bar.render.test.ts +67 -0
  157. package/src/components/selection-top-bar.stories.tsx +39 -0
  158. package/src/components/selection-top-bar.tsx +95 -0
  159. package/src/components/sender-group-switch.tsx +63 -0
  160. package/src/components/sender-trust-indicator.render.test.ts +67 -0
  161. package/src/components/sender-trust-indicator.stories.tsx +58 -0
  162. package/src/components/sender-trust-indicator.tsx +87 -0
  163. package/src/components/settings-primitives.render.test.ts +160 -0
  164. package/src/components/settings-primitives.stories.tsx +80 -0
  165. package/src/components/settings-screen.tsx +354 -0
  166. package/src/components/swipeable-row.render.test.ts +106 -0
  167. package/src/components/swipeable-row.stories.tsx +117 -0
  168. package/src/components/swipeable-row.tsx +268 -0
  169. package/src/components/touch-list-body.render.test.ts +66 -0
  170. package/src/components/touch-list-body.stories.tsx +76 -0
  171. package/src/components/touch-list.tsx +87 -0
  172. package/src/components/wizard.render.test.ts +49 -0
  173. package/src/components/wizard.stories.tsx +113 -0
  174. package/src/components/wizard.tsx +321 -0
  175. package/src/filter-presets.test.ts +93 -0
  176. package/src/filter-presets.ts +101 -0
  177. package/src/index.ts +378 -0
  178. package/src/lib/cid-resolver.ts +35 -0
  179. package/src/lib/cn.ts +7 -0
  180. package/src/lib/email-layout-clamp.test.ts +109 -0
  181. package/src/lib/email-layout-clamp.ts +64 -0
  182. package/src/lib/email-render-treatment.test.ts +82 -0
  183. package/src/lib/email-render-treatment.ts +53 -0
  184. package/src/lib/email-sanitizer.test.ts +362 -0
  185. package/src/lib/email-sanitizer.ts +298 -0
  186. package/src/lib/row-keyboard.test.ts +41 -0
  187. package/src/lib/row-keyboard.ts +20 -0
  188. package/src/tokens.css +217 -0
@@ -0,0 +1,53 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { OutboxRow } from "./outbox-row.js";
3
+
4
+ const meta: Meta<typeof OutboxRow> = {
5
+ title: "Mail/OutboxRow",
6
+ component: OutboxRow,
7
+ parameters: { layout: "padded" },
8
+ args: {
9
+ recipients: "alex@example.com +2",
10
+ subject: "Q3 planning notes",
11
+ time: "9:42",
12
+ onSelect: () => undefined,
13
+ onEdit: () => undefined,
14
+ onDelete: () => undefined,
15
+ },
16
+ };
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof OutboxRow>;
20
+
21
+ export const Queued: Story = { args: { status: "queued" } };
22
+
23
+ export const Sending: Story = { args: { status: "sending" } };
24
+
25
+ export const Sent: Story = { args: { status: "sent" } };
26
+
27
+ export const Failed: Story = {
28
+ args: {
29
+ status: "failed",
30
+ error: "SMTP connection refused",
31
+ onRetry: () => undefined,
32
+ },
33
+ };
34
+
35
+ export const Blocked: Story = {
36
+ args: {
37
+ status: "blocked",
38
+ error: "SMTP not configured for this account",
39
+ },
40
+ };
41
+
42
+ export const Selected: Story = {
43
+ args: { status: "queued", selected: true },
44
+ };
45
+
46
+ export const Empty: Story = {
47
+ name: "Empty list",
48
+ render: () => (
49
+ <div className="flex h-32 items-center justify-center text-sm text-fg-muted">
50
+ No outbox messages
51
+ </div>
52
+ ),
53
+ };
@@ -0,0 +1,120 @@
1
+ import { RotateCcw, Send, Trash2 } from "lucide-react";
2
+ import { cn } from "../lib/cn.js";
3
+ import type { OutboxStatus } from "./outbox-status-badge.js";
4
+ import { OutboxStatusBadge } from "./outbox-status-badge.js";
5
+ import { RowActions } from "./row-actions.js";
6
+
7
+ export interface OutboxRowProps {
8
+ recipients: string;
9
+ subject: string;
10
+ /** Pre-formatted timestamp shown at the row's trailing edge. */
11
+ time: string;
12
+ status: OutboxStatus;
13
+ /** Surfaced after the status label for failed/blocked rows. */
14
+ error?: string;
15
+ selected?: boolean;
16
+ onSelect: () => void;
17
+ /** Retry sending — present only for the `failed` status. */
18
+ onRetry?: () => void;
19
+ retrying?: boolean;
20
+ onEdit: () => void;
21
+ onDelete: () => void;
22
+ deleting?: boolean;
23
+ }
24
+
25
+ const tintForStatus = (status: OutboxStatus, selected?: boolean): string => {
26
+ if (selected) return "bg-accent-2-soft";
27
+ if (status === "failed") return "bg-danger-soft";
28
+ if (status === "blocked") return "bg-warning/10";
29
+ return "";
30
+ };
31
+
32
+ /**
33
+ * Status-tinted outbox message row composing OutboxStatusBadge and the shared
34
+ * RowActions cluster. Presentational — the live route wires the callbacks to
35
+ * mutations.
36
+ */
37
+ export function OutboxRow({
38
+ recipients,
39
+ subject,
40
+ time,
41
+ status,
42
+ error,
43
+ selected,
44
+ onSelect,
45
+ onRetry,
46
+ retrying,
47
+ onEdit,
48
+ onDelete,
49
+ deleting,
50
+ }: OutboxRowProps) {
51
+ const showActions = status === "failed" || status === "blocked";
52
+
53
+ return (
54
+ <div
55
+ className={cn(
56
+ "flex items-start gap-3 border-b border-line px-4 py-3 transition-colors hover:bg-surface-raised",
57
+ tintForStatus(status, selected),
58
+ )}
59
+ >
60
+ <button
61
+ type="button"
62
+ onClick={onSelect}
63
+ className="flex flex-1 min-w-0 items-start gap-3 text-left"
64
+ >
65
+ <div className="mt-0.5 shrink-0">
66
+ <OutboxStatusBadge status={status} iconOnly />
67
+ </div>
68
+ <div className="min-w-0 flex-1">
69
+ <div className="flex items-center justify-between gap-2">
70
+ <span className="truncate text-sm font-medium">{recipients}</span>
71
+ <span className="shrink-0 text-xs text-fg-muted">{time}</span>
72
+ </div>
73
+ <div className="truncate text-sm">{subject || "No subject"}</div>
74
+ <div className="mt-1 flex items-center gap-2">
75
+ <OutboxStatusBadge status={status} />
76
+ {error && (
77
+ <span className="truncate text-xs text-fg-muted">— {error}</span>
78
+ )}
79
+ </div>
80
+ </div>
81
+ </button>
82
+ {showActions && (
83
+ <div className="shrink-0">
84
+ <RowActions
85
+ actions={[
86
+ ...(status === "failed" && onRetry
87
+ ? [
88
+ {
89
+ label: "Retry sending",
90
+ iconOnly: true,
91
+ icon: <RotateCcw className="size-3.5" />,
92
+ busy: retrying,
93
+ onClick: onRetry,
94
+ } as const,
95
+ ]
96
+ : []),
97
+ {
98
+ label: "Edit as draft",
99
+ iconOnly: true,
100
+ icon: <Send className="size-3.5" />,
101
+ onClick: onEdit,
102
+ },
103
+ ]}
104
+ destructive={{
105
+ label: "Delete message",
106
+ iconOnly: true,
107
+ icon: <Trash2 className="size-3.5" />,
108
+ busy: deleting,
109
+ confirm: {
110
+ prompt: "Delete this message?",
111
+ confirmLabel: "Delete",
112
+ },
113
+ onClick: onDelete,
114
+ }}
115
+ />
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ AlertCircle,
3
+ AlertTriangle,
4
+ CheckCircle,
5
+ Clock,
6
+ Loader2,
7
+ } from "lucide-react";
8
+ import { cn } from "../lib/cn.js";
9
+
10
+ /** The non-draft outbox lifecycle states (drafts live in a separate view). */
11
+ export type OutboxStatus = "queued" | "sending" | "sent" | "failed" | "blocked";
12
+
13
+ interface StatusConfig {
14
+ icon: typeof Clock;
15
+ label: string;
16
+ className: string;
17
+ spin?: boolean;
18
+ }
19
+
20
+ export const outboxStatusConfig: Record<OutboxStatus, StatusConfig> = {
21
+ queued: { icon: Clock, label: "Queued", className: "text-warning" },
22
+ sending: {
23
+ icon: Loader2,
24
+ label: "Sending…",
25
+ className: "text-accent-2",
26
+ spin: true,
27
+ },
28
+ sent: { icon: CheckCircle, label: "Sent", className: "text-positive" },
29
+ failed: { icon: AlertCircle, label: "Failed", className: "text-danger" },
30
+ blocked: { icon: AlertTriangle, label: "Blocked", className: "text-warning" },
31
+ };
32
+
33
+ export interface OutboxStatusBadgeProps {
34
+ status: OutboxStatus;
35
+ /** Hide the text label, leaving only the tinted icon. */
36
+ iconOnly?: boolean;
37
+ className?: string;
38
+ }
39
+
40
+ /** Status-tinted icon + label for an outbox message row. */
41
+ export function OutboxStatusBadge({
42
+ status,
43
+ iconOnly,
44
+ className,
45
+ }: OutboxStatusBadgeProps) {
46
+ const config = outboxStatusConfig[status];
47
+ const Icon = config.icon;
48
+ return (
49
+ <span
50
+ className={cn(
51
+ "inline-flex items-center gap-1.5 text-xs font-medium",
52
+ config.className,
53
+ className,
54
+ )}
55
+ >
56
+ <Icon className={cn("size-4", config.spin && "animate-spin")} />
57
+ {!iconOnly && config.label}
58
+ </span>
59
+ );
60
+ }
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { type PaneLayout, resolvePaneLayout } from "./app-shell-types.js";
4
+
5
+ /**
6
+ * The single responsive surface reflows by its own width. These cases pin the
7
+ * pane-count-by-width rule the AppShell render obeys (both read the same
8
+ * READING_PANE_MIN_WIDTH / INTELLIGENCE_MIN_WIDTH constants):
9
+ * phone + tablet PORTRAIT (<1024) → list ALONE, no reading pane
10
+ * tablet landscape / desktop (1024–1279) → list + reading
11
+ * widest (≥1280) → + intelligence rail
12
+ */
13
+ const paneCount = (l: PaneLayout) =>
14
+ Number(l.nav) + 1 + Number(l.reading) + Number(l.intelligence);
15
+
16
+ describe("resolvePaneLayout — pane count by width", () => {
17
+ it("phone (390): list alone, one pane", () => {
18
+ const l = resolvePaneLayout(390);
19
+ assert.equal(l.reading, false, "no reading pane on phone");
20
+ assert.equal(l.intelligence, false);
21
+ assert.equal(paneCount(l), 1, "single pane");
22
+ });
23
+
24
+ it("tablet portrait (768): list alone, no reading pane", () => {
25
+ const l = resolvePaneLayout(768);
26
+ assert.equal(l.reading, false, "portrait tablet collapses to the list");
27
+ assert.equal(paneCount(l), 1);
28
+ });
29
+
30
+ it("tablet portrait (834, iPad): still list alone", () => {
31
+ const l = resolvePaneLayout(834);
32
+ assert.equal(l.reading, false);
33
+ assert.equal(paneCount(l), 1);
34
+ });
35
+
36
+ it("just below the reading boundary (1023): still list alone", () => {
37
+ assert.equal(resolvePaneLayout(1023).reading, false);
38
+ });
39
+
40
+ it("tablet landscape / desktop floor (1024): list + reading + nav", () => {
41
+ const l = resolvePaneLayout(1024);
42
+ assert.equal(l.reading, true, "reading pane appears at 1024");
43
+ assert.equal(l.nav, true, "nav becomes a persistent pane");
44
+ assert.equal(l.intelligence, false, "rail not yet — only at the widest");
45
+ assert.equal(paneCount(l), 3);
46
+ });
47
+
48
+ it("iPad landscape (1112): two content panes, no rail", () => {
49
+ const l = resolvePaneLayout(1112);
50
+ assert.equal(l.reading, true);
51
+ assert.equal(l.intelligence, false);
52
+ });
53
+
54
+ it("just below the rail boundary (1279): no rail", () => {
55
+ assert.equal(resolvePaneLayout(1279).intelligence, false);
56
+ });
57
+
58
+ it("widest (1280): + intelligence rail, four panes", () => {
59
+ const l = resolvePaneLayout(1280);
60
+ assert.equal(l.reading, true);
61
+ assert.equal(l.intelligence, true);
62
+ assert.equal(paneCount(l), 4);
63
+ });
64
+
65
+ it("monotonic: panes only ever appear as width grows", () => {
66
+ const widths = [320, 768, 834, 1024, 1112, 1280, 1920];
67
+ const counts = widths.map((w) => paneCount(resolvePaneLayout(w)));
68
+ const sorted = [...counts].sort((a, b) => a - b);
69
+ assert.deepEqual(counts, sorted, "pane count is non-decreasing in width");
70
+ });
71
+ });
72
+
73
+ describe("resolvePaneLayout — configurable thresholds (#900/#901)", () => {
74
+ it("respects a custom reading-pane boundary", () => {
75
+ assert.equal(
76
+ resolvePaneLayout(767, 768, 1280).reading,
77
+ false,
78
+ "just below custom reading boundary",
79
+ );
80
+ assert.equal(
81
+ resolvePaneLayout(768, 768, 1280).reading,
82
+ true,
83
+ "at the custom reading boundary",
84
+ );
85
+ });
86
+
87
+ it("respects a custom intelligence boundary", () => {
88
+ assert.equal(
89
+ resolvePaneLayout(1023, 1024, 1024).intelligence,
90
+ false,
91
+ "just below custom intelligence boundary",
92
+ );
93
+ assert.equal(
94
+ resolvePaneLayout(1024, 1024, 1024).intelligence,
95
+ true,
96
+ "at the custom intelligence boundary",
97
+ );
98
+ });
99
+
100
+ it("defaults still match the module constants when no overrides given", () => {
101
+ const withDefaults = resolvePaneLayout(1024);
102
+ const withExplicit = resolvePaneLayout(1024, 1024, 1280);
103
+ assert.deepEqual(withDefaults, withExplicit, "defaults are identical");
104
+ });
105
+ });
@@ -0,0 +1,32 @@
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 { PopoverMenu } from "./popover-menu.js";
6
+
7
+ const item = {
8
+ key: "read",
9
+ label: "Mark as read",
10
+ onSelect: () => undefined,
11
+ };
12
+
13
+ describe("PopoverMenu", () => {
14
+ it("renders the trigger with its accessible label and menu semantics", () => {
15
+ const html = renderToString(
16
+ createElement(PopoverMenu, {
17
+ triggerLabel: "More actions",
18
+ items: [item],
19
+ }),
20
+ );
21
+ assert.match(html, /aria-label="More actions"/);
22
+ assert.match(html, /aria-haspopup="menu"/);
23
+ assert.match(html, /aria-expanded="false"/);
24
+ });
25
+
26
+ it("renders nothing when there are no items", () => {
27
+ const html = renderToString(
28
+ createElement(PopoverMenu, { triggerLabel: "More actions", items: [] }),
29
+ );
30
+ assert.equal(html, "");
31
+ });
32
+ });
@@ -0,0 +1,57 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Mail, MailOpen, Tag } from "lucide-react";
3
+ import { PopoverMenu } from "./popover-menu.js";
4
+
5
+ const meta: Meta<typeof PopoverMenu> = {
6
+ title: "Kit/PopoverMenu",
7
+ component: PopoverMenu,
8
+ parameters: { layout: "centered" },
9
+ render: (args) => (
10
+ <div className="flex h-64 w-72 items-start justify-end p-4">
11
+ <PopoverMenu {...args} />
12
+ </div>
13
+ ),
14
+ };
15
+ export default meta;
16
+
17
+ type Story = StoryObj<typeof PopoverMenu>;
18
+
19
+ export const Default: Story = {
20
+ args: {
21
+ triggerLabel: "More actions",
22
+ items: [
23
+ {
24
+ key: "read",
25
+ label: "Mark as read",
26
+ icon: <MailOpen className="size-4" />,
27
+ onSelect: () => undefined,
28
+ },
29
+ {
30
+ key: "label",
31
+ label: "Add label",
32
+ icon: <Tag className="size-4" />,
33
+ onSelect: () => undefined,
34
+ },
35
+ ],
36
+ },
37
+ };
38
+
39
+ export const SingleItem: Story = {
40
+ args: {
41
+ triggerLabel: "More actions",
42
+ items: [
43
+ {
44
+ key: "unread",
45
+ label: "Mark as unread",
46
+ icon: <Mail className="size-4" />,
47
+ onSelect: () => undefined,
48
+ },
49
+ ],
50
+ },
51
+ };
52
+
53
+ /** With no items the kebab is dead weight, so it renders nothing rather than a
54
+ * disabled control. */
55
+ export const Empty: Story = {
56
+ args: { triggerLabel: "More actions", items: [] },
57
+ };
@@ -0,0 +1,110 @@
1
+ import { EllipsisVertical } from "lucide-react";
2
+ import { type ReactNode, useEffect, useRef, useState } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Button } from "./button.js";
5
+
6
+ export interface PopoverMenuItem {
7
+ /** Stable key, also the accessible text of the row. */
8
+ key: string;
9
+ label: string;
10
+ icon?: ReactNode;
11
+ onSelect: () => void;
12
+ }
13
+
14
+ export interface PopoverMenuProps {
15
+ /** Accessible label for the trigger button. */
16
+ triggerLabel: string;
17
+ /** Trigger glyph. Defaults to the vertical ellipsis (kebab). */
18
+ triggerIcon?: ReactNode;
19
+ items: PopoverMenuItem[];
20
+ /** Which edge the menu aligns to. Defaults to "end" (right). */
21
+ align?: "start" | "end";
22
+ /** Touch-sizes the trigger to ≥44px. Defaults to true. */
23
+ touch?: boolean;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * A small touch dropdown menu: a kebab trigger over a list of action rows,
29
+ * dismissed on outside-click or Escape. Built on the kit `Button` for the
30
+ * trigger; rows are ≥44px for touch ergonomics. The home for the secondary
31
+ * 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.
34
+ */
35
+ export function PopoverMenu({
36
+ triggerLabel,
37
+ triggerIcon,
38
+ items,
39
+ align = "end",
40
+ touch = true,
41
+ className,
42
+ }: PopoverMenuProps) {
43
+ const [open, setOpen] = useState(false);
44
+ const containerRef = useRef<HTMLDivElement>(null);
45
+
46
+ useEffect(() => {
47
+ if (!open) return;
48
+ const onPointer = (event: MouseEvent) => {
49
+ if (
50
+ containerRef.current &&
51
+ !containerRef.current.contains(event.target as Node)
52
+ ) {
53
+ setOpen(false);
54
+ }
55
+ };
56
+ const onKey = (event: KeyboardEvent) => {
57
+ if (event.key === "Escape") setOpen(false);
58
+ };
59
+ document.addEventListener("mousedown", onPointer);
60
+ document.addEventListener("keydown", onKey);
61
+ return () => {
62
+ document.removeEventListener("mousedown", onPointer);
63
+ document.removeEventListener("keydown", onKey);
64
+ };
65
+ }, [open]);
66
+
67
+ if (items.length === 0) return null;
68
+
69
+ return (
70
+ <div ref={containerRef} className={cn("relative", className)}>
71
+ <Button
72
+ variant="ghost"
73
+ size="sm"
74
+ icon={triggerIcon ?? <EllipsisVertical className="size-5" />}
75
+ onClick={() => setOpen((value) => !value)}
76
+ aria-label={triggerLabel}
77
+ aria-haspopup="menu"
78
+ aria-expanded={open}
79
+ className={cn(touch && "min-h-11 min-w-11 px-0")}
80
+ />
81
+ {open && (
82
+ <div
83
+ role="menu"
84
+ 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",
86
+ align === "end" ? "right-0" : "left-0",
87
+ )}
88
+ >
89
+ {items.map((item) => (
90
+ <button
91
+ key={item.key}
92
+ type="button"
93
+ role="menuitem"
94
+ onClick={() => {
95
+ setOpen(false);
96
+ item.onSelect();
97
+ }}
98
+ className="flex min-h-11 items-center gap-3 px-4 py-2.5 text-left text-sm text-fg transition-colors hover:bg-surface-sunken"
99
+ >
100
+ {item.icon && (
101
+ <span className="shrink-0 text-fg-subtle">{item.icon}</span>
102
+ )}
103
+ {item.label}
104
+ </button>
105
+ ))}
106
+ </div>
107
+ )}
108
+ </div>
109
+ );
110
+ }