@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.
- package/package.json +50 -0
- package/src/components/address-display.render.test.ts +93 -0
- package/src/components/address-display.stories.tsx +55 -0
- package/src/components/address-display.tsx +132 -0
- package/src/components/address-tag.render.test.ts +39 -0
- package/src/components/address-tag.stories.tsx +41 -0
- package/src/components/address-tag.tsx +38 -0
- package/src/components/app-password-hint.render.test.ts +23 -0
- package/src/components/app-password-hint.stories.tsx +19 -0
- package/src/components/app-password-hint.tsx +24 -0
- package/src/components/app-shell-slotted.render.test.ts +128 -0
- package/src/components/app-shell-slotted.tsx +267 -0
- package/src/components/app-shell-types.ts +328 -0
- package/src/components/app-shell.render.test.ts +415 -0
- package/src/components/app-shell.tsx +277 -0
- package/src/components/auth-card.render.test.ts +24 -0
- package/src/components/auth-card.stories.tsx +50 -0
- package/src/components/auth-card.tsx +32 -0
- package/src/components/auth-footer.render.test.ts +17 -0
- package/src/components/auth-footer.stories.tsx +23 -0
- package/src/components/auth-footer.tsx +14 -0
- package/src/components/auth-hero.render.test.ts +31 -0
- package/src/components/auth-hero.stories.tsx +19 -0
- package/src/components/auth-hero.tsx +41 -0
- package/src/components/auto-moved-badge.render.test.ts +42 -0
- package/src/components/auto-moved-badge.stories.tsx +48 -0
- package/src/components/auto-moved-badge.tsx +57 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +49 -0
- package/src/components/banner.render.test.ts +58 -0
- package/src/components/banner.stories.tsx +92 -0
- package/src/components/banner.tsx +81 -0
- package/src/components/bottom-sheet.render.test.ts +48 -0
- package/src/components/bottom-sheet.stories.tsx +71 -0
- package/src/components/bottom-sheet.tsx +146 -0
- package/src/components/brief-section.render.test.ts +98 -0
- package/src/components/brief-section.stories.tsx +68 -0
- package/src/components/brief-section.tsx +102 -0
- package/src/components/brief-sections.render.test.ts +72 -0
- package/src/components/brief-sections.stories.tsx +178 -0
- package/src/components/brief-sections.tsx +201 -0
- package/src/components/button.tsx +48 -0
- package/src/components/card.tsx +48 -0
- package/src/components/category-badge.render.test.ts +40 -0
- package/src/components/category-badge.stories.tsx +37 -0
- package/src/components/category-badge.tsx +71 -0
- package/src/components/checkbox.render.test.ts +65 -0
- package/src/components/checkbox.stories.tsx +77 -0
- package/src/components/checkbox.tsx +78 -0
- package/src/components/compose-action-bar.render.test.ts +47 -0
- package/src/components/compose-action-bar.stories.tsx +35 -0
- package/src/components/compose-action-bar.tsx +95 -0
- package/src/components/compose-form-shell.render.test.ts +54 -0
- package/src/components/compose-form-shell.stories.tsx +96 -0
- package/src/components/compose-form-shell.tsx +49 -0
- package/src/components/danger-zone-section.tsx +36 -0
- package/src/components/dialog.render.test.ts +55 -0
- package/src/components/dialog.tsx +104 -0
- package/src/components/email-frame-css.ts +137 -0
- package/src/components/field-label.render.test.ts +16 -0
- package/src/components/field-label.tsx +19 -0
- package/src/components/filter-sheet.render.test.ts +76 -0
- package/src/components/filter-sheet.stories.tsx +146 -0
- package/src/components/filter-sheet.tsx +316 -0
- package/src/components/folder-role.tsx +66 -0
- package/src/components/input.tsx +44 -0
- package/src/components/intelligence-panel.stories.tsx +149 -0
- package/src/components/intelligence-panel.tsx +546 -0
- package/src/components/isolated-email-frame.render.test.ts +147 -0
- package/src/components/isolated-email-frame.stories.tsx +192 -0
- package/src/components/isolated-email-frame.tsx +265 -0
- package/src/components/kbd.tsx +17 -0
- package/src/components/keyboard-hint-bar.tsx +58 -0
- package/src/components/list-item.tsx +39 -0
- package/src/components/mail-action-toolbar.render.test.ts +43 -0
- package/src/components/mail-action-toolbar.tsx +180 -0
- package/src/components/mail-header.render.test.ts +69 -0
- package/src/components/mail-header.stories.tsx +233 -0
- package/src/components/mail-header.tsx +123 -0
- package/src/components/message-body-view.render.test.ts +59 -0
- package/src/components/message-body-view.stories.tsx +146 -0
- package/src/components/message-body-view.tsx +150 -0
- package/src/components/message-header.render.test.ts +58 -0
- package/src/components/message-header.stories.tsx +79 -0
- package/src/components/message-header.tsx +59 -0
- package/src/components/message-list-pane.render.test.ts +136 -0
- package/src/components/message-list-pane.stories.tsx +246 -0
- package/src/components/message-list-pane.tsx +248 -0
- package/src/components/message-list-state.render.test.ts +72 -0
- package/src/components/message-list-state.tsx +97 -0
- package/src/components/message-row.render.test.ts +48 -0
- package/src/components/message-row.stories.tsx +121 -0
- package/src/components/message-row.tsx +231 -0
- package/src/components/mobile-message-action-bar.render.test.ts +87 -0
- package/src/components/mobile-message-action-bar.stories.tsx +57 -0
- package/src/components/mobile-message-action-bar.tsx +175 -0
- package/src/components/mobile-reading-pane.render.test.ts +102 -0
- package/src/components/mobile-reading-pane.tsx +188 -0
- package/src/components/mobile-search-view.stories.tsx +247 -0
- package/src/components/mobile-search-view.tsx +100 -0
- package/src/components/move-mailbox-picker.render.test.ts +113 -0
- package/src/components/move-mailbox-picker.stories.tsx +76 -0
- package/src/components/move-mailbox-picker.tsx +324 -0
- package/src/components/nav-sidebar.render.test.ts +351 -0
- package/src/components/nav-sidebar.stories.tsx +216 -0
- package/src/components/nav-sidebar.tsx +625 -0
- package/src/components/outbox-row.render.test.ts +61 -0
- package/src/components/outbox-row.stories.tsx +53 -0
- package/src/components/outbox-row.tsx +120 -0
- package/src/components/outbox-status-badge.tsx +60 -0
- package/src/components/pane-layout.render.test.ts +105 -0
- package/src/components/popover-menu.render.test.ts +32 -0
- package/src/components/popover-menu.stories.tsx +57 -0
- package/src/components/popover-menu.tsx +110 -0
- package/src/components/primitives.stories.tsx +173 -0
- package/src/components/pull-to-refresh.render.test.ts +46 -0
- package/src/components/pull-to-refresh.stories.tsx +71 -0
- package/src/components/pull-to-refresh.tsx +54 -0
- package/src/components/quoted-text.render.test.ts +39 -0
- package/src/components/quoted-text.stories.tsx +67 -0
- package/src/components/quoted-text.tsx +67 -0
- package/src/components/reading-pane-empty.tsx +44 -0
- package/src/components/reading-pane.render.test.ts +148 -0
- package/src/components/reading-pane.stories.tsx +194 -0
- package/src/components/reading-pane.tsx +351 -0
- package/src/components/rescue-banner.render.test.ts +32 -0
- package/src/components/rescue-banner.stories.tsx +31 -0
- package/src/components/rescue-banner.tsx +50 -0
- package/src/components/rescue-candidate-row.render.test.ts +68 -0
- package/src/components/rescue-candidate-row.stories.tsx +108 -0
- package/src/components/rescue-candidate-row.tsx +94 -0
- package/src/components/rescue-from-spam-flow.render.test.ts +100 -0
- package/src/components/rescue-from-spam-flow.stories.tsx +92 -0
- package/src/components/rescue-from-spam-flow.tsx +342 -0
- package/src/components/resizable.tsx +55 -0
- package/src/components/role-appointment-list.render.test.ts +97 -0
- package/src/components/role-appointment-list.stories.tsx +138 -0
- package/src/components/role-appointment-list.tsx +227 -0
- package/src/components/row-actions.render.test.ts +83 -0
- package/src/components/row-actions.stories.tsx +87 -0
- package/src/components/row-actions.tsx +139 -0
- package/src/components/search-bar.render.test.ts +55 -0
- package/src/components/search-bar.stories.tsx +46 -0
- package/src/components/search-bar.tsx +118 -0
- package/src/components/search-result-row.tsx +139 -0
- package/src/components/search-results.render.test.ts +89 -0
- package/src/components/search-results.stories.tsx +126 -0
- package/src/components/search-results.tsx +215 -0
- package/src/components/search-token-chip.render.test.ts +42 -0
- package/src/components/search-token-chip.stories.tsx +42 -0
- package/src/components/search-token-chip.tsx +66 -0
- package/src/components/security-select.render.test.ts +33 -0
- package/src/components/security-select.tsx +53 -0
- package/src/components/segmented-control.tsx +70 -0
- package/src/components/select.tsx +33 -0
- package/src/components/selection-top-bar.render.test.ts +67 -0
- package/src/components/selection-top-bar.stories.tsx +39 -0
- package/src/components/selection-top-bar.tsx +95 -0
- package/src/components/sender-group-switch.tsx +63 -0
- package/src/components/sender-trust-indicator.render.test.ts +67 -0
- package/src/components/sender-trust-indicator.stories.tsx +58 -0
- package/src/components/sender-trust-indicator.tsx +87 -0
- package/src/components/settings-primitives.render.test.ts +160 -0
- package/src/components/settings-primitives.stories.tsx +80 -0
- package/src/components/settings-screen.tsx +354 -0
- package/src/components/swipeable-row.render.test.ts +106 -0
- package/src/components/swipeable-row.stories.tsx +117 -0
- package/src/components/swipeable-row.tsx +268 -0
- package/src/components/touch-list-body.render.test.ts +66 -0
- package/src/components/touch-list-body.stories.tsx +76 -0
- package/src/components/touch-list.tsx +87 -0
- package/src/components/wizard.render.test.ts +49 -0
- package/src/components/wizard.stories.tsx +113 -0
- package/src/components/wizard.tsx +321 -0
- package/src/filter-presets.test.ts +93 -0
- package/src/filter-presets.ts +101 -0
- package/src/index.ts +378 -0
- package/src/lib/cid-resolver.ts +35 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/email-layout-clamp.test.ts +109 -0
- package/src/lib/email-layout-clamp.ts +64 -0
- package/src/lib/email-render-treatment.test.ts +82 -0
- package/src/lib/email-render-treatment.ts +53 -0
- package/src/lib/email-sanitizer.test.ts +362 -0
- package/src/lib/email-sanitizer.ts +298 -0
- package/src/lib/row-keyboard.test.ts +41 -0
- package/src/lib/row-keyboard.ts +20 -0
- 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
|
+
}
|