@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
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remit/ui",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"src"
|
|
7
|
+
],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./tokens.css": "./src/tokens.css"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "npm run test:typecheck && npm run test:run",
|
|
14
|
+
"test:typecheck": "tsgo --noEmit",
|
|
15
|
+
"test:run": "node --import tsx --test 'src/**/*.test.ts'"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@fontsource-variable/geist": "^5",
|
|
19
|
+
"@fontsource-variable/hanken-grotesk": "^5",
|
|
20
|
+
"@fontsource-variable/inter": "^5",
|
|
21
|
+
"clsx": "^2",
|
|
22
|
+
"dompurify": "^3.4.7",
|
|
23
|
+
"lucide-react": "^0.468",
|
|
24
|
+
"react-resizable-panels": "^2.1.9",
|
|
25
|
+
"react-simple-pull-to-refresh": "^1.3.4",
|
|
26
|
+
"tailwind-merge": "^2"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"react": "^19",
|
|
30
|
+
"react-dom": "^19"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@storybook/react": "^9",
|
|
34
|
+
"@types/dompurify": "*",
|
|
35
|
+
"@types/react": "^19",
|
|
36
|
+
"@types/react-dom": "^19",
|
|
37
|
+
"react": "^19",
|
|
38
|
+
"react-dom": "^19",
|
|
39
|
+
"typescript": "*"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/remit-mail/reader.git",
|
|
48
|
+
"directory": "packages/ui"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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 {
|
|
6
|
+
AddressDisplay,
|
|
7
|
+
AddressList,
|
|
8
|
+
type EnvelopeAddress,
|
|
9
|
+
} from "./address-display.js";
|
|
10
|
+
|
|
11
|
+
const named: EnvelopeAddress = {
|
|
12
|
+
displayName: "Ada Lovelace",
|
|
13
|
+
normalizedEmail: "ada@example.com",
|
|
14
|
+
};
|
|
15
|
+
const bare: EnvelopeAddress = { normalizedEmail: "grace@example.com" };
|
|
16
|
+
|
|
17
|
+
describe("AddressDisplay", () => {
|
|
18
|
+
it("renders display name with the address in angle brackets", () => {
|
|
19
|
+
const html = renderToString(
|
|
20
|
+
createElement(AddressDisplay, { address: named }),
|
|
21
|
+
);
|
|
22
|
+
assert.match(html, /Ada Lovelace/);
|
|
23
|
+
assert.match(html, /ada@example.com/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("falls back to the bare email when no display name", () => {
|
|
27
|
+
const html = renderToString(
|
|
28
|
+
createElement(AddressDisplay, { address: bare }),
|
|
29
|
+
);
|
|
30
|
+
assert.match(html, /grace@example.com/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("shows the trusted badge only when asked", () => {
|
|
34
|
+
assert.match(
|
|
35
|
+
renderToString(
|
|
36
|
+
createElement(AddressDisplay, {
|
|
37
|
+
address: named,
|
|
38
|
+
showTrustedBadge: true,
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
/data-testid="trusted-sender-badge"/,
|
|
42
|
+
);
|
|
43
|
+
assert.doesNotMatch(
|
|
44
|
+
renderToString(createElement(AddressDisplay, { address: named })),
|
|
45
|
+
/trusted-sender-badge/,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("AddressList", () => {
|
|
51
|
+
it("renders nothing for an empty list", () => {
|
|
52
|
+
assert.equal(
|
|
53
|
+
renderToString(
|
|
54
|
+
createElement(AddressList, { label: "To", addresses: [] }),
|
|
55
|
+
),
|
|
56
|
+
"",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("shows the expand control only when there are more than three addresses", () => {
|
|
61
|
+
const three = [bare, bare, bare];
|
|
62
|
+
const four = [bare, bare, bare, bare];
|
|
63
|
+
assert.doesNotMatch(
|
|
64
|
+
renderToString(
|
|
65
|
+
createElement(AddressList, { label: "To", addresses: three }),
|
|
66
|
+
),
|
|
67
|
+
/aria-label="Expand"/,
|
|
68
|
+
);
|
|
69
|
+
assert.match(
|
|
70
|
+
renderToString(
|
|
71
|
+
createElement(AddressList, { label: "To", addresses: four }),
|
|
72
|
+
),
|
|
73
|
+
/aria-label="Expand"/,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("renders the trusted badge for trusted From addresses when enabled", () => {
|
|
78
|
+
const trusted: EnvelopeAddress = {
|
|
79
|
+
...named,
|
|
80
|
+
flags: { trusted: { value: true } },
|
|
81
|
+
};
|
|
82
|
+
assert.match(
|
|
83
|
+
renderToString(
|
|
84
|
+
createElement(AddressList, {
|
|
85
|
+
label: "From",
|
|
86
|
+
addresses: [trusted],
|
|
87
|
+
showTrustedBadge: true,
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
/trusted-sender-badge/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { AddressList, type EnvelopeAddress } from "./address-display.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof AddressList> = {
|
|
5
|
+
title: "Mail/AddressList",
|
|
6
|
+
component: AddressList,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof AddressList>;
|
|
12
|
+
|
|
13
|
+
const named = (name: string, email: string): EnvelopeAddress => ({
|
|
14
|
+
displayName: name,
|
|
15
|
+
normalizedEmail: email,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const trusted: EnvelopeAddress = {
|
|
19
|
+
...named("Ada Lovelace", "ada@example.com"),
|
|
20
|
+
flags: { trusted: { value: true } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const SingleFromTrusted: Story = {
|
|
24
|
+
name: "From — trusted",
|
|
25
|
+
args: {
|
|
26
|
+
label: "From",
|
|
27
|
+
addresses: [trusted],
|
|
28
|
+
showTrustedBadge: true,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const FewRecipients: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
label: "To",
|
|
35
|
+
addresses: [
|
|
36
|
+
named("Grace Hopper", "grace@example.com"),
|
|
37
|
+
named("Alan Turing", "alan@example.com"),
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const ManyRecipientsCollapsed: Story = {
|
|
43
|
+
name: "Many recipients (expandable)",
|
|
44
|
+
args: {
|
|
45
|
+
label: "To",
|
|
46
|
+
addresses: [
|
|
47
|
+
named("Grace Hopper", "grace@example.com"),
|
|
48
|
+
named("Alan Turing", "alan@example.com"),
|
|
49
|
+
named("Katherine Johnson", "katherine@example.com"),
|
|
50
|
+
named("Dorothy Vaughan", "dorothy@example.com"),
|
|
51
|
+
named("Mary Jackson", "mary@example.com"),
|
|
52
|
+
{ normalizedEmail: "no-name@example.com" },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { BadgeCheck, ChevronDown, ChevronRight } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal address shape the display needs. Structurally compatible with the
|
|
6
|
+
* generated `RemitImapEnvelopeAddressResponse` so call sites can pass envelope
|
|
7
|
+
* addresses straight through, but the kit owns no app types.
|
|
8
|
+
*/
|
|
9
|
+
export interface EnvelopeAddress {
|
|
10
|
+
displayName?: string;
|
|
11
|
+
normalizedEmail: string;
|
|
12
|
+
flags?: {
|
|
13
|
+
trusted?: { value: boolean };
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AddressDisplayProps {
|
|
18
|
+
address: EnvelopeAddress;
|
|
19
|
+
/**
|
|
20
|
+
* When true, render a small "trusted sender" checkmark next to the
|
|
21
|
+
* address. Driven by `address.flags?.trusted?.value === true` upstream
|
|
22
|
+
* so the check is centralized in the parent that knows the role
|
|
23
|
+
* (e.g. only From-addresses get the indicator).
|
|
24
|
+
*/
|
|
25
|
+
showTrustedBadge?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const AddressDisplay = ({
|
|
29
|
+
address,
|
|
30
|
+
showTrustedBadge = false,
|
|
31
|
+
}: AddressDisplayProps) => {
|
|
32
|
+
const badge = showTrustedBadge ? (
|
|
33
|
+
<BadgeCheck
|
|
34
|
+
className="inline-block size-4 ml-1 -mt-0.5 text-positive"
|
|
35
|
+
aria-label="Trusted sender"
|
|
36
|
+
data-testid="trusted-sender-badge"
|
|
37
|
+
/>
|
|
38
|
+
) : null;
|
|
39
|
+
|
|
40
|
+
if (address.displayName) {
|
|
41
|
+
return (
|
|
42
|
+
<span>
|
|
43
|
+
<span className="text-fg">{address.displayName}</span>
|
|
44
|
+
<span className="text-fg-muted">
|
|
45
|
+
{" "}
|
|
46
|
+
<{address.normalizedEmail}>
|
|
47
|
+
</span>
|
|
48
|
+
{badge}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return (
|
|
53
|
+
<span className="text-fg">
|
|
54
|
+
{address.normalizedEmail}
|
|
55
|
+
{badge}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface AddressListProps {
|
|
61
|
+
label: string;
|
|
62
|
+
addresses: EnvelopeAddress[];
|
|
63
|
+
/**
|
|
64
|
+
* When true, addresses with `flags.trusted.value === true` render a
|
|
65
|
+
* green checkmark. Only set this for the From row; trusted-status of
|
|
66
|
+
* To/Cc senders is irrelevant to the current viewer.
|
|
67
|
+
*/
|
|
68
|
+
showTrustedBadge?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const AddressList = ({
|
|
72
|
+
label,
|
|
73
|
+
addresses,
|
|
74
|
+
showTrustedBadge = false,
|
|
75
|
+
}: AddressListProps) => {
|
|
76
|
+
const [expanded, setExpanded] = useState(false);
|
|
77
|
+
|
|
78
|
+
if (addresses.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
const hasMany = addresses.length > 3;
|
|
81
|
+
|
|
82
|
+
const renderAddress = (addr: EnvelopeAddress) => {
|
|
83
|
+
const isTrusted = showTrustedBadge && addr.flags?.trusted?.value === true;
|
|
84
|
+
return <AddressDisplay address={addr} showTrustedBadge={isTrusted} />;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex gap-2 text-sm">
|
|
89
|
+
<span className="text-fg-muted shrink-0 w-12">{label}:</span>
|
|
90
|
+
<div className="flex-1 min-w-0">
|
|
91
|
+
{expanded ? (
|
|
92
|
+
<div className="flex flex-wrap gap-1">
|
|
93
|
+
{addresses.map((addr, idx) => (
|
|
94
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
|
|
95
|
+
<span key={idx}>
|
|
96
|
+
{renderAddress(addr)}
|
|
97
|
+
{idx < addresses.length - 1 && ", "}
|
|
98
|
+
</span>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
) : (
|
|
102
|
+
<div className="truncate">
|
|
103
|
+
{addresses.map((addr, idx) => (
|
|
104
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
|
|
105
|
+
<span key={idx}>
|
|
106
|
+
{renderAddress(addr)}
|
|
107
|
+
{idx < addresses.length - 1 && ", "}
|
|
108
|
+
</span>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
{hasMany && (
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={(e) => {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
setExpanded(!expanded);
|
|
119
|
+
}}
|
|
120
|
+
className="shrink-0 text-fg-muted hover:text-fg transition-colors"
|
|
121
|
+
aria-label={expanded ? "Collapse" : "Expand"}
|
|
122
|
+
>
|
|
123
|
+
{expanded ? (
|
|
124
|
+
<ChevronDown className="h-4 w-4" />
|
|
125
|
+
) : (
|
|
126
|
+
<ChevronRight className="h-4 w-4" />
|
|
127
|
+
)}
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { AddressTag } from "./address-tag.js";
|
|
6
|
+
|
|
7
|
+
describe("AddressTag", () => {
|
|
8
|
+
it("renders the display name when present", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(AddressTag, {
|
|
11
|
+
email: "alex@example.com",
|
|
12
|
+
displayName: "Alex Rivera",
|
|
13
|
+
onRemove: () => {},
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
assert.match(html, /Alex Rivera/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("falls back to the bare address when no display name", () => {
|
|
20
|
+
const html = renderToString(
|
|
21
|
+
createElement(AddressTag, {
|
|
22
|
+
email: "sam@example.com",
|
|
23
|
+
onRemove: () => {},
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
assert.match(html, /sam@example\.com/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("labels the remove button with the address", () => {
|
|
30
|
+
const html = renderToString(
|
|
31
|
+
createElement(AddressTag, {
|
|
32
|
+
email: "alex@example.com",
|
|
33
|
+
displayName: "Alex Rivera",
|
|
34
|
+
onRemove: () => {},
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
assert.match(html, /aria-label="Remove alex@example\.com"/);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { AddressTag } from "./address-tag.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof AddressTag> = {
|
|
5
|
+
title: "Compose/AddressTag",
|
|
6
|
+
component: AddressTag,
|
|
7
|
+
parameters: { layout: "centered" },
|
|
8
|
+
args: { onRemove: () => {} },
|
|
9
|
+
};
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
type Story = StoryObj<typeof AddressTag>;
|
|
13
|
+
|
|
14
|
+
export const EmailOnly: Story = {
|
|
15
|
+
args: { email: "alex@example.com" },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const WithDisplayName: Story = {
|
|
19
|
+
args: { email: "alex@example.com", displayName: "Alex Rivera" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const LongAddress: Story = {
|
|
23
|
+
args: { email: "very.long.recipient.address@really-long-domain.example.com" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Removable: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
29
|
+
<AddressTag
|
|
30
|
+
email="alex@example.com"
|
|
31
|
+
displayName="Alex Rivera"
|
|
32
|
+
onRemove={() => {}}
|
|
33
|
+
/>
|
|
34
|
+
<AddressTag email="sam@example.com" onRemove={() => {}} />
|
|
35
|
+
<AddressTag
|
|
36
|
+
email="very.long.recipient.address@really-long-domain.example.com"
|
|
37
|
+
onRemove={() => {}}
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
),
|
|
41
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
|
|
4
|
+
export interface AddressTagProps {
|
|
5
|
+
email: string;
|
|
6
|
+
displayName?: string;
|
|
7
|
+
onRemove: () => void;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Dismissible recipient chip for compose fields and filter UIs. Shows the
|
|
13
|
+
* display name when present, falling back to the bare address, and truncates
|
|
14
|
+
* to keep long addresses from breaking the field layout.
|
|
15
|
+
*/
|
|
16
|
+
export const AddressTag = ({
|
|
17
|
+
email,
|
|
18
|
+
displayName,
|
|
19
|
+
onRemove,
|
|
20
|
+
className,
|
|
21
|
+
}: AddressTagProps) => (
|
|
22
|
+
<span
|
|
23
|
+
className={cn(
|
|
24
|
+
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent-2-soft text-sm max-w-[200px]",
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
28
|
+
<span className="truncate">{displayName || email}</span>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={onRemove}
|
|
32
|
+
className="shrink-0 p-0.5 rounded-full hover:bg-fg-muted/20 transition-colors"
|
|
33
|
+
aria-label={`Remove ${email}`}
|
|
34
|
+
>
|
|
35
|
+
<X className="size-3" />
|
|
36
|
+
</button>
|
|
37
|
+
</span>
|
|
38
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
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 { AppPasswordHint } from "./app-password-hint.js";
|
|
6
|
+
|
|
7
|
+
describe("AppPasswordHint", () => {
|
|
8
|
+
it("renders a deep link when a url is provided", () => {
|
|
9
|
+
const html = renderToString(
|
|
10
|
+
createElement(AppPasswordHint, {
|
|
11
|
+
url: "https://support.apple.com/en-us/102654",
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
assert.match(html, /href="https:\/\/support\.apple\.com\/en-us\/102654"/);
|
|
15
|
+
assert.match(html, /How to create an app password/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders the generic fallback when no url is provided", () => {
|
|
19
|
+
const html = renderToString(createElement(AppPasswordHint, {}));
|
|
20
|
+
assert.doesNotMatch(html, /<a /);
|
|
21
|
+
assert.match(html, /Check your provider.*help for app password/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { AppPasswordHint } from "./app-password-hint.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof AppPasswordHint> = {
|
|
5
|
+
title: "Onboarding/AppPasswordHint",
|
|
6
|
+
component: AppPasswordHint,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
};
|
|
9
|
+
export default meta;
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof AppPasswordHint>;
|
|
12
|
+
|
|
13
|
+
export const KnownProvider: Story = {
|
|
14
|
+
args: { url: "https://support.apple.com/en-us/102654" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const UnknownProvider: Story = {
|
|
18
|
+
args: {},
|
|
19
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface AppPasswordHintProps {
|
|
2
|
+
/** Resolved provider deep link; omit for the generic fallback. */
|
|
3
|
+
url?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function AppPasswordHint({ url }: AppPasswordHintProps) {
|
|
7
|
+
return (
|
|
8
|
+
<p className="text-2xs text-fg-subtle">
|
|
9
|
+
Stored encrypted, used only to connect to your mail server.{" "}
|
|
10
|
+
{url ? (
|
|
11
|
+
<a
|
|
12
|
+
href={url}
|
|
13
|
+
target="_blank"
|
|
14
|
+
rel="noopener noreferrer"
|
|
15
|
+
className="text-accent underline"
|
|
16
|
+
>
|
|
17
|
+
How to create an app password
|
|
18
|
+
</a>
|
|
19
|
+
) : (
|
|
20
|
+
<span>Check your provider's help for app password instructions.</span>
|
|
21
|
+
)}
|
|
22
|
+
</p>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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 type { AppShellSlottedProps } from "./app-shell-slotted.js";
|
|
6
|
+
import { AppShellSlotted } from "./app-shell-slotted.js";
|
|
7
|
+
|
|
8
|
+
const render = (overrides: Partial<AppShellSlottedProps> = {}) =>
|
|
9
|
+
renderToString(
|
|
10
|
+
createElement(AppShellSlotted, {
|
|
11
|
+
nav: createElement("div", { "data-testid": "nav" }, "Nav"),
|
|
12
|
+
list: createElement("div", { "data-testid": "list" }, "List"),
|
|
13
|
+
...overrides,
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe("AppShellSlotted slot rendering", () => {
|
|
18
|
+
it("renders the list slot unconditionally", () => {
|
|
19
|
+
const html = render({ initialWidth: 800 });
|
|
20
|
+
assert.match(html, /data-testid="list"/, "list slot is always rendered");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders the reading slot at/above 1024px", () => {
|
|
24
|
+
const html = render({
|
|
25
|
+
initialWidth: 1100,
|
|
26
|
+
reading: createElement("div", { "data-testid": "reading" }, "Reading"),
|
|
27
|
+
});
|
|
28
|
+
assert.match(
|
|
29
|
+
html,
|
|
30
|
+
/data-testid="reading"/,
|
|
31
|
+
"reading pane appears at 1024px",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("hides the reading slot below 1024px", () => {
|
|
36
|
+
const html = render({
|
|
37
|
+
initialWidth: 800,
|
|
38
|
+
reading: createElement("div", { "data-testid": "reading" }, "Reading"),
|
|
39
|
+
});
|
|
40
|
+
assert.doesNotMatch(
|
|
41
|
+
html,
|
|
42
|
+
/data-testid="reading"/,
|
|
43
|
+
"reading pane is absent below 1024px",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders the intelligence slot at/above 1280px when intelligenceOpen", () => {
|
|
48
|
+
const html = render({
|
|
49
|
+
initialWidth: 1400,
|
|
50
|
+
reading: createElement("div", { "data-testid": "reading" }, "Reading"),
|
|
51
|
+
intelligence: createElement(
|
|
52
|
+
"div",
|
|
53
|
+
{ "data-testid": "intelligence" },
|
|
54
|
+
"Intel",
|
|
55
|
+
),
|
|
56
|
+
intelligenceOpen: true,
|
|
57
|
+
});
|
|
58
|
+
assert.match(
|
|
59
|
+
html,
|
|
60
|
+
/data-testid="intelligence"/,
|
|
61
|
+
"intelligence pane appears at 1280px",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("hides the intelligence slot when intelligenceOpen is false", () => {
|
|
66
|
+
const html = render({
|
|
67
|
+
initialWidth: 1400,
|
|
68
|
+
reading: createElement("div", { "data-testid": "reading" }, "Reading"),
|
|
69
|
+
intelligence: createElement(
|
|
70
|
+
"div",
|
|
71
|
+
{ "data-testid": "intelligence" },
|
|
72
|
+
"Intel",
|
|
73
|
+
),
|
|
74
|
+
intelligenceOpen: false,
|
|
75
|
+
});
|
|
76
|
+
assert.doesNotMatch(
|
|
77
|
+
html,
|
|
78
|
+
/data-testid="intelligence"/,
|
|
79
|
+
"intelligence pane is hidden when closed",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("renders the nav as a pane at/above 1024px (no folders trigger)", () => {
|
|
84
|
+
const html = render({ initialWidth: 1100 });
|
|
85
|
+
assert.match(html, /data-testid="nav"/, "nav is in the pane");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("shows the header slot only below 1024px", () => {
|
|
89
|
+
const headerEl = createElement(
|
|
90
|
+
"div",
|
|
91
|
+
{ "data-testid": "header" },
|
|
92
|
+
"Header",
|
|
93
|
+
);
|
|
94
|
+
const narrowHtml = render({ initialWidth: 800, header: headerEl });
|
|
95
|
+
const wideHtml = render({ initialWidth: 1100, header: headerEl });
|
|
96
|
+
assert.match(
|
|
97
|
+
narrowHtml,
|
|
98
|
+
/data-testid="header"/,
|
|
99
|
+
"header shows on narrow widths",
|
|
100
|
+
);
|
|
101
|
+
assert.doesNotMatch(
|
|
102
|
+
wideHtml,
|
|
103
|
+
/data-testid="header"/,
|
|
104
|
+
"header is hidden at/above 1024px",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("renders the overlay regardless of width", () => {
|
|
109
|
+
const overlayEl = createElement("div", { "data-testid": "overlay" }, "FAB");
|
|
110
|
+
const narrowHtml = render({ initialWidth: 800, overlay: overlayEl });
|
|
111
|
+
const wideHtml = render({ initialWidth: 1100, overlay: overlayEl });
|
|
112
|
+
assert.match(narrowHtml, /data-testid="overlay"/, "overlay on narrow");
|
|
113
|
+
assert.match(wideHtml, /data-testid="overlay"/, "overlay on desktop");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("renders the skeleton when isLoading is true", () => {
|
|
117
|
+
const html = render({
|
|
118
|
+
isLoading: true,
|
|
119
|
+
skeleton: createElement("div", { "data-testid": "skeleton" }, "Loading"),
|
|
120
|
+
});
|
|
121
|
+
assert.match(html, /data-testid="skeleton"/, "skeleton is rendered");
|
|
122
|
+
assert.doesNotMatch(
|
|
123
|
+
html,
|
|
124
|
+
/data-testid="list"/,
|
|
125
|
+
"list is hidden while loading",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
});
|