@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,42 @@
|
|
|
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 { SearchTokenChip, SearchTokenChips } from "./search-token-chip.js";
|
|
6
|
+
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
|
|
9
|
+
describe("SearchTokenChip", () => {
|
|
10
|
+
it("renders the label and a remove control", () => {
|
|
11
|
+
const html = renderToString(
|
|
12
|
+
createElement(SearchTokenChip, {
|
|
13
|
+
label: "Has attachment",
|
|
14
|
+
onRemove: noop,
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
assert.match(html, /Has attachment/);
|
|
18
|
+
assert.match(html, /aria-label="Remove filter: Has attachment"/);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("SearchTokenChips", () => {
|
|
23
|
+
it("renders nothing for an empty token list", () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
renderToString(createElement(SearchTokenChips, { tokens: [] })),
|
|
26
|
+
"",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders one chip per token", () => {
|
|
31
|
+
const html = renderToString(
|
|
32
|
+
createElement(SearchTokenChips, {
|
|
33
|
+
tokens: [
|
|
34
|
+
{ label: "Unread", onRemove: noop },
|
|
35
|
+
{ label: "From: alice", onRemove: noop },
|
|
36
|
+
],
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
assert.match(html, /Unread/);
|
|
40
|
+
assert.match(html, /From: alice/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { SearchTokenChips } from "./search-token-chip.js";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof SearchTokenChips> = {
|
|
6
|
+
title: "Mail/SearchTokenChips",
|
|
7
|
+
component: SearchTokenChips,
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
type Story = StoryObj<typeof SearchTokenChips>;
|
|
13
|
+
|
|
14
|
+
const initialLabels = ["From: dhl.com", "Has attachment", "Unread"];
|
|
15
|
+
|
|
16
|
+
function Interactive() {
|
|
17
|
+
const [labels, setLabels] = useState(initialLabels);
|
|
18
|
+
return (
|
|
19
|
+
<div className="w-96 rounded-md border border-line">
|
|
20
|
+
<SearchTokenChips
|
|
21
|
+
tokens={labels.map((label) => ({
|
|
22
|
+
label,
|
|
23
|
+
onRemove: () => setLabels((prev) => prev.filter((l) => l !== label)),
|
|
24
|
+
}))}
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Removable filter-token chips under the search field; click × to drop one. */
|
|
31
|
+
export const Tokens: Story = {
|
|
32
|
+
render: () => <Interactive />,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** No recognized tokens in the query — the row collapses to nothing. */
|
|
36
|
+
export const Empty: Story = {
|
|
37
|
+
render: () => (
|
|
38
|
+
<div className="w-96 rounded-md border border-line">
|
|
39
|
+
<SearchTokenChips tokens={[]} />
|
|
40
|
+
</div>
|
|
41
|
+
),
|
|
42
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface SearchTokenChipProps {
|
|
5
|
+
label: string;
|
|
6
|
+
onRemove: () => void;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One removable filter-token chip (`from:`, `has:attachment`, …) shown under
|
|
12
|
+
* the search field once the field's typed text parses a recognized token.
|
|
13
|
+
* Same dismissible-pill treatment as `AddressTag`, generalized to a plain
|
|
14
|
+
* label since a token chip carries no email identity.
|
|
15
|
+
*/
|
|
16
|
+
export const SearchTokenChip = ({
|
|
17
|
+
label,
|
|
18
|
+
onRemove,
|
|
19
|
+
className,
|
|
20
|
+
}: SearchTokenChipProps) => (
|
|
21
|
+
<span
|
|
22
|
+
className={cn(
|
|
23
|
+
"inline-flex items-center gap-1 rounded-full border border-line bg-surface-sunken px-2 py-0.5 text-2xs text-fg-muted",
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
>
|
|
27
|
+
<span>{label}</span>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={onRemove}
|
|
31
|
+
className="shrink-0 rounded-full p-0.5 hover:bg-fg-muted/20 transition-colors"
|
|
32
|
+
aria-label={`Remove filter: ${label}`}
|
|
33
|
+
>
|
|
34
|
+
<X className="size-3" />
|
|
35
|
+
</button>
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export interface SearchTokenChipsProps {
|
|
40
|
+
tokens: { label: string; onRemove: () => void }[];
|
|
41
|
+
className?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Wraps the active filter-token chips in a row under the search field. */
|
|
45
|
+
export const SearchTokenChips = ({
|
|
46
|
+
tokens,
|
|
47
|
+
className,
|
|
48
|
+
}: SearchTokenChipsProps) => {
|
|
49
|
+
if (tokens.length === 0) return null;
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={cn(
|
|
53
|
+
"flex flex-wrap items-center gap-1.5 border-b border-line bg-surface px-row-inset py-1.5",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{tokens.map((token) => (
|
|
58
|
+
<SearchTokenChip
|
|
59
|
+
key={token.label}
|
|
60
|
+
label={token.label}
|
|
61
|
+
onRemove={token.onRemove}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
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 { SecuritySelect, securityToApi } from "./security-select.js";
|
|
6
|
+
|
|
7
|
+
describe("securityToApi", () => {
|
|
8
|
+
it("maps tls to { tls: true, startTls: false }", () => {
|
|
9
|
+
assert.deepEqual(securityToApi("tls"), { tls: true, startTls: false });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("maps starttls to { tls: false, startTls: true }", () => {
|
|
13
|
+
assert.deepEqual(securityToApi("starttls"), {
|
|
14
|
+
tls: false,
|
|
15
|
+
startTls: true,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("maps none to { tls: false, startTls: false }", () => {
|
|
20
|
+
assert.deepEqual(securityToApi("none"), { tls: false, startTls: false });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("SecuritySelect", () => {
|
|
25
|
+
it("renders all three security options", () => {
|
|
26
|
+
const html = renderToString(
|
|
27
|
+
createElement(SecuritySelect, { defaultValue: "tls" }),
|
|
28
|
+
);
|
|
29
|
+
assert.match(html, /TLS\/SSL/);
|
|
30
|
+
assert.match(html, /STARTTLS/);
|
|
31
|
+
assert.match(html, /None \(insecure\)/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SelectProps } from "./select.js";
|
|
2
|
+
import { Select } from "./select.js";
|
|
3
|
+
|
|
4
|
+
export type ServerSecurity = "tls" | "starttls" | "none";
|
|
5
|
+
|
|
6
|
+
export interface SecuritySelectProps
|
|
7
|
+
extends Omit<
|
|
8
|
+
SelectProps,
|
|
9
|
+
"value" | "defaultValue" | "onChange" | "children"
|
|
10
|
+
> {
|
|
11
|
+
value?: ServerSecurity;
|
|
12
|
+
defaultValue?: ServerSecurity;
|
|
13
|
+
onValueChange?: (value: ServerSecurity) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SecuritySelect({
|
|
17
|
+
value,
|
|
18
|
+
defaultValue,
|
|
19
|
+
onValueChange,
|
|
20
|
+
...props
|
|
21
|
+
}: SecuritySelectProps) {
|
|
22
|
+
if (onValueChange) {
|
|
23
|
+
return (
|
|
24
|
+
<Select
|
|
25
|
+
value={value}
|
|
26
|
+
onChange={(e) => onValueChange(e.target.value as ServerSecurity)}
|
|
27
|
+
{...props}
|
|
28
|
+
>
|
|
29
|
+
<option value="tls">TLS/SSL</option>
|
|
30
|
+
<option value="starttls">STARTTLS</option>
|
|
31
|
+
<option value="none">None (insecure)</option>
|
|
32
|
+
</Select>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Select defaultValue={defaultValue ?? value} {...props}>
|
|
38
|
+
<option value="tls">TLS/SSL</option>
|
|
39
|
+
<option value="starttls">STARTTLS</option>
|
|
40
|
+
<option value="none">None (insecure)</option>
|
|
41
|
+
</Select>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function securityToApi(security: ServerSecurity): {
|
|
46
|
+
tls: boolean;
|
|
47
|
+
startTls: boolean;
|
|
48
|
+
} {
|
|
49
|
+
return {
|
|
50
|
+
tls: security === "tls",
|
|
51
|
+
startTls: security === "starttls",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
|
|
3
|
+
export interface SegmentedOption<T extends string> {
|
|
4
|
+
value: T;
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SegmentedControlProps<T extends string> {
|
|
9
|
+
name: string;
|
|
10
|
+
options: SegmentedOption<T>[];
|
|
11
|
+
value: T;
|
|
12
|
+
onChange: (value: T) => void;
|
|
13
|
+
size?: "sm" | "md";
|
|
14
|
+
"aria-label"?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sizeClass = {
|
|
18
|
+
sm: "min-h-9 px-3 text-xs",
|
|
19
|
+
md: "min-h-11 px-4 text-sm",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function SegmentedControl<T extends string>({
|
|
23
|
+
name,
|
|
24
|
+
options,
|
|
25
|
+
value,
|
|
26
|
+
onChange,
|
|
27
|
+
size = "md",
|
|
28
|
+
"aria-label": ariaLabel,
|
|
29
|
+
}: SegmentedControlProps<T>) {
|
|
30
|
+
const activeIndex = options.findIndex((o) => o.value === value);
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
role="radiogroup"
|
|
34
|
+
aria-label={ariaLabel}
|
|
35
|
+
className="inline-flex rounded-md border border-line bg-surface-sunken p-0.5"
|
|
36
|
+
>
|
|
37
|
+
{options.map((option, i) => {
|
|
38
|
+
const selected = option.value === value;
|
|
39
|
+
// Show a divider on the right edge when neither this segment nor its
|
|
40
|
+
// right neighbour is active — keeps the line away from the thumb.
|
|
41
|
+
const showDivider =
|
|
42
|
+
!selected && i < options.length - 1 && activeIndex !== i + 1;
|
|
43
|
+
return (
|
|
44
|
+
<label
|
|
45
|
+
key={option.value}
|
|
46
|
+
className={cn(
|
|
47
|
+
"relative flex cursor-pointer items-center justify-center rounded-[5px] font-medium transition-colors",
|
|
48
|
+
sizeClass[size],
|
|
49
|
+
selected
|
|
50
|
+
? "bg-surface text-fg shadow"
|
|
51
|
+
: "text-fg-muted hover:text-fg",
|
|
52
|
+
showDivider &&
|
|
53
|
+
"after:pointer-events-none after:absolute after:right-0 after:top-1/4 after:h-1/2 after:w-px after:bg-line",
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<input
|
|
57
|
+
type="radio"
|
|
58
|
+
name={name}
|
|
59
|
+
value={option.value}
|
|
60
|
+
checked={selected}
|
|
61
|
+
onChange={() => onChange(option.value)}
|
|
62
|
+
className="sr-only"
|
|
63
|
+
/>
|
|
64
|
+
{option.label}
|
|
65
|
+
</label>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ChevronDown } from "lucide-react";
|
|
2
|
+
import type { ReactNode, SelectHTMLAttributes } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
|
|
5
|
+
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
6
|
+
/** Optional leading icon (lucide-react element). */
|
|
7
|
+
icon?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Native select styled to match Input: same height, hairline border,
|
|
12
|
+
* sunken surface, focus ring. Options render in <option> children.
|
|
13
|
+
*/
|
|
14
|
+
export function Select({ icon, className, children, ...props }: SelectProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className={cn(
|
|
18
|
+
"relative flex h-9 items-center gap-2 rounded-md border border-line bg-surface-sunken pl-3 text-sm",
|
|
19
|
+
"focus-within:border-line-strong focus-within:ring-2 focus-within:ring-ring/30 transition-colors",
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
>
|
|
23
|
+
{icon && <span className="shrink-0 text-fg-subtle">{icon}</span>}
|
|
24
|
+
<select
|
|
25
|
+
className="min-w-0 flex-1 appearance-none bg-transparent py-0 pr-8 text-fg outline-none"
|
|
26
|
+
{...props}
|
|
27
|
+
>
|
|
28
|
+
{children}
|
|
29
|
+
</select>
|
|
30
|
+
<ChevronDown className="pointer-events-none absolute right-2.5 size-4 text-fg-subtle" />
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { SelectionTopBar } from "./selection-top-bar.js";
|
|
6
|
+
|
|
7
|
+
const handlers = {
|
|
8
|
+
onCancel: () => undefined,
|
|
9
|
+
onDelete: () => undefined,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** SSR interleaves `<!-- -->` markers between interpolated text nodes; strip
|
|
13
|
+
* tags/comments so copy assertions match the rendered words. */
|
|
14
|
+
const text = (html: string) => html.replace(/<[^>]*>/g, "");
|
|
15
|
+
|
|
16
|
+
describe("SelectionTopBar", () => {
|
|
17
|
+
it("renders singular copy for one message", () => {
|
|
18
|
+
const html = renderToString(
|
|
19
|
+
createElement(SelectionTopBar, { ...handlers, count: 1 }),
|
|
20
|
+
);
|
|
21
|
+
assert.match(text(html), /1 message selected/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders plural copy for many messages", () => {
|
|
25
|
+
const html = renderToString(
|
|
26
|
+
createElement(SelectionTopBar, { ...handlers, count: 3 }),
|
|
27
|
+
);
|
|
28
|
+
assert.match(text(html), /3 messages selected/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders cancel and delete controls", () => {
|
|
32
|
+
const html = renderToString(
|
|
33
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
34
|
+
);
|
|
35
|
+
assert.match(html, /aria-label="Cancel selection"/);
|
|
36
|
+
assert.match(html, /aria-label="Delete selected messages"/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders mark-read control when onMarkRead is provided", () => {
|
|
40
|
+
const html = renderToString(
|
|
41
|
+
createElement(SelectionTopBar, {
|
|
42
|
+
...handlers,
|
|
43
|
+
count: 2,
|
|
44
|
+
onMarkRead: () => undefined,
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
assert.match(html, /aria-label="Mark as read"/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("omits mark-read control when onMarkRead is absent", () => {
|
|
51
|
+
const html = renderToString(
|
|
52
|
+
createElement(SelectionTopBar, { ...handlers, count: 2 }),
|
|
53
|
+
);
|
|
54
|
+
assert.doesNotMatch(html, /aria-label="Mark as read"/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders moveDisabledHint when provided", () => {
|
|
58
|
+
const html = renderToString(
|
|
59
|
+
createElement(SelectionTopBar, {
|
|
60
|
+
...handlers,
|
|
61
|
+
count: 2,
|
|
62
|
+
moveDisabledHint: "Cross-account moves are not supported",
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
assert.match(html, /Cross-account moves are not supported/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { SelectionTopBar } from "./selection-top-bar.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof SelectionTopBar> = {
|
|
5
|
+
title: "Screens/Kit/SelectionTopBar",
|
|
6
|
+
component: SelectionTopBar,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
args: {
|
|
9
|
+
onCancel: () => undefined,
|
|
10
|
+
onMarkRead: () => undefined,
|
|
11
|
+
onDelete: () => undefined,
|
|
12
|
+
},
|
|
13
|
+
render: (args) => (
|
|
14
|
+
<div className="w-[390px] rounded-md border border-line">
|
|
15
|
+
<SelectionTopBar {...args} />
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
};
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
type Story = StoryObj<typeof SelectionTopBar>;
|
|
22
|
+
|
|
23
|
+
export const One: Story = { args: { count: 1 } };
|
|
24
|
+
|
|
25
|
+
export const Many: Story = { args: { count: 3 } };
|
|
26
|
+
|
|
27
|
+
export const WithoutMarkRead: Story = {
|
|
28
|
+
args: { count: 2, onMarkRead: undefined },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const Busy: Story = { args: { count: 2, isBusy: true } };
|
|
32
|
+
|
|
33
|
+
export const CrossAccountHint: Story = {
|
|
34
|
+
args: {
|
|
35
|
+
count: 4,
|
|
36
|
+
moveDisabledHint:
|
|
37
|
+
"Move only works within one account — clear selection or pick messages from a single account",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Loader2, MailOpen, Trash2, X } from "lucide-react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Button } from "./button.js";
|
|
4
|
+
|
|
5
|
+
export interface SelectionTopBarProps {
|
|
6
|
+
count: number;
|
|
7
|
+
onCancel: () => void;
|
|
8
|
+
onDelete: () => void;
|
|
9
|
+
/** Optional — hide the mark-read button when omitted. */
|
|
10
|
+
onMarkRead?: () => void;
|
|
11
|
+
/**
|
|
12
|
+
* Slot for a move-to-folder trigger. Rendered between mark-read and delete.
|
|
13
|
+
* Kept as a render prop so the caller controls API dependencies.
|
|
14
|
+
*/
|
|
15
|
+
moveSlot?: ReactNode;
|
|
16
|
+
/**
|
|
17
|
+
* Cross-account hint surfaced below the action row. When set, Move is
|
|
18
|
+
* expected to be suppressed by the caller (via moveSlot).
|
|
19
|
+
*/
|
|
20
|
+
moveDisabledHint?: string;
|
|
21
|
+
/**
|
|
22
|
+
* True while a delete or move mutation is in flight. The delete button
|
|
23
|
+
* shows a spinner; other actions no-op. Never disables controls.
|
|
24
|
+
*/
|
|
25
|
+
isBusy?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replaces the list header in narrow-width multi-select: a count plus the bulk
|
|
30
|
+
* verbs (cancel, mark read, move, delete). Real Buttons that never disable.
|
|
31
|
+
*/
|
|
32
|
+
export function SelectionTopBar({
|
|
33
|
+
count,
|
|
34
|
+
onCancel,
|
|
35
|
+
onMarkRead,
|
|
36
|
+
onDelete,
|
|
37
|
+
moveSlot,
|
|
38
|
+
moveDisabledHint,
|
|
39
|
+
isBusy = false,
|
|
40
|
+
}: SelectionTopBarProps) {
|
|
41
|
+
return (
|
|
42
|
+
<header className="flex shrink-0 flex-col border-b border-line bg-surface-sunken">
|
|
43
|
+
<div className="flex h-pane-header items-center gap-2 px-row-inset">
|
|
44
|
+
<Button
|
|
45
|
+
variant="ghost"
|
|
46
|
+
size="sm"
|
|
47
|
+
icon={<X className="size-4" />}
|
|
48
|
+
onClick={onCancel}
|
|
49
|
+
aria-label="Cancel selection"
|
|
50
|
+
className="-ml-1 shrink-0"
|
|
51
|
+
/>
|
|
52
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium text-fg">
|
|
53
|
+
{count} {count === 1 ? "message" : "messages"} selected
|
|
54
|
+
</span>
|
|
55
|
+
{onMarkRead && (
|
|
56
|
+
<Button
|
|
57
|
+
variant="ghost"
|
|
58
|
+
size="sm"
|
|
59
|
+
icon={<MailOpen className="size-4" />}
|
|
60
|
+
onClick={isBusy ? undefined : onMarkRead}
|
|
61
|
+
aria-label="Mark as read"
|
|
62
|
+
aria-busy={isBusy || undefined}
|
|
63
|
+
className="shrink-0"
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
{moveSlot}
|
|
67
|
+
<Button
|
|
68
|
+
variant="ghost"
|
|
69
|
+
size="sm"
|
|
70
|
+
icon={
|
|
71
|
+
isBusy ? (
|
|
72
|
+
<Loader2 className="size-4 animate-spin" />
|
|
73
|
+
) : (
|
|
74
|
+
<Trash2 className="size-4 text-danger" />
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
onClick={isBusy ? undefined : onDelete}
|
|
78
|
+
aria-label="Delete selected messages"
|
|
79
|
+
aria-busy={isBusy || undefined}
|
|
80
|
+
className="shrink-0"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
{moveDisabledHint && (
|
|
84
|
+
// biome-ignore lint/a11y/useSemanticElements: <p> with role="status" preserves block layout; <output> is inline
|
|
85
|
+
<p
|
|
86
|
+
className="px-row-inset pb-2 text-xs text-fg-muted"
|
|
87
|
+
role="status"
|
|
88
|
+
aria-live="polite"
|
|
89
|
+
>
|
|
90
|
+
{moveDisabledHint}
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
93
|
+
</header>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
|
|
3
|
+
export interface SenderGroupOption<T extends string> {
|
|
4
|
+
id: T;
|
|
5
|
+
label: string;
|
|
6
|
+
count: number | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SenderGroupSwitchProps<T extends string> {
|
|
10
|
+
options: SenderGroupOption<T>[];
|
|
11
|
+
active: T;
|
|
12
|
+
onSelect: (id: T) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* VIP / Muted / Blocked selector for Senders & Rules. A vertical rail on
|
|
17
|
+
* desktop; a horizontal tab strip below it so the dense table can own the
|
|
18
|
+
* full width on phone and tablet.
|
|
19
|
+
*/
|
|
20
|
+
export function SenderGroupSwitch<T extends string>({
|
|
21
|
+
options,
|
|
22
|
+
active,
|
|
23
|
+
onSelect,
|
|
24
|
+
}: SenderGroupSwitchProps<T>) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
role="tablist"
|
|
28
|
+
className={cn(
|
|
29
|
+
"flex shrink-0 gap-1 overflow-x-auto border-b border-line p-2",
|
|
30
|
+
"lg:w-44 lg:flex-col lg:gap-0 lg:overflow-visible lg:border-r lg:border-b-0 lg:py-2 lg:pr-2 lg:pl-3",
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{options.map((option) => {
|
|
34
|
+
const selected = option.id === active;
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
key={option.id}
|
|
38
|
+
type="button"
|
|
39
|
+
role="tab"
|
|
40
|
+
aria-selected={selected}
|
|
41
|
+
onClick={() => onSelect(option.id)}
|
|
42
|
+
className={cn(
|
|
43
|
+
"flex min-h-11 shrink-0 items-center gap-2 rounded-md px-3 text-left text-sm transition-colors lg:min-h-0 lg:px-2 lg:py-1",
|
|
44
|
+
selected
|
|
45
|
+
? "bg-accent-2-soft font-medium text-accent-2"
|
|
46
|
+
: "text-fg-muted hover:bg-surface-sunken hover:text-fg",
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
<span className="flex-1 truncate">{option.label}</span>
|
|
50
|
+
<span
|
|
51
|
+
className={cn(
|
|
52
|
+
"text-2xs tabular-nums",
|
|
53
|
+
selected ? "text-accent-2" : "text-fg-subtle",
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
{option.count ?? "—"}
|
|
57
|
+
</span>
|
|
58
|
+
</button>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it, test } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
SenderTrustIndicator,
|
|
7
|
+
selectSenderTrustVariant,
|
|
8
|
+
} from "./sender-trust-indicator.js";
|
|
9
|
+
|
|
10
|
+
describe("selectSenderTrustVariant", () => {
|
|
11
|
+
test("vip renders sparkles at every size", () => {
|
|
12
|
+
assert.equal(selectSenderTrustVariant("vip", "sm"), "vip");
|
|
13
|
+
assert.equal(selectSenderTrustVariant("vip", "md"), "vip");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("wellknown is hidden at every size", () => {
|
|
17
|
+
assert.equal(selectSenderTrustVariant("wellknown", "sm"), "hidden");
|
|
18
|
+
assert.equal(selectSenderTrustVariant("wellknown", "md"), "hidden");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("unknown is hidden on inbox rows (sm) — most senders default to unknown post-rollout, so a per-row pill would be noise", () => {
|
|
22
|
+
assert.equal(selectSenderTrustVariant("unknown", "sm"), "hidden");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("unknown shows the pill only at md (open-message header) where it flags first-message-from-this-sender", () => {
|
|
26
|
+
assert.equal(selectSenderTrustVariant("unknown", "md"), "unknown-pill");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("SenderTrustIndicator", () => {
|
|
31
|
+
it("renders the VIP sparkles", () => {
|
|
32
|
+
const html = renderToString(
|
|
33
|
+
createElement(SenderTrustIndicator, { senderTrust: "vip" }),
|
|
34
|
+
);
|
|
35
|
+
assert.match(html, /aria-label="VIP sender"/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders the new-sender pill only at md", () => {
|
|
39
|
+
assert.match(
|
|
40
|
+
renderToString(
|
|
41
|
+
createElement(SenderTrustIndicator, {
|
|
42
|
+
senderTrust: "unknown",
|
|
43
|
+
size: "md",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
/First message from this sender/,
|
|
47
|
+
);
|
|
48
|
+
assert.equal(
|
|
49
|
+
renderToString(
|
|
50
|
+
createElement(SenderTrustIndicator, {
|
|
51
|
+
senderTrust: "unknown",
|
|
52
|
+
size: "sm",
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
"",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders nothing for wellknown", () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
renderToString(
|
|
62
|
+
createElement(SenderTrustIndicator, { senderTrust: "wellknown" }),
|
|
63
|
+
),
|
|
64
|
+
"",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|