@remit/ui 0.0.70 → 0.0.72
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 +1 -1
- package/src/components/auto-moved-badge.tsx +1 -3
- package/src/components/brief-sections.tsx +0 -13
- package/src/components/dialog.tsx +2 -5
- package/src/components/filter-rule-editor.tsx +0 -43
- package/src/components/filter-rule.render.test.ts +0 -49
- package/src/components/folder-tree-picker.create.test.ts +150 -20
- package/src/components/folder-tree-picker.render.test.ts +3 -2
- package/src/components/folder-tree-picker.stories.tsx +41 -0
- package/src/components/folder-tree-picker.tsx +20 -2
- package/src/components/primitives.stories.tsx +0 -30
- package/src/components/search-chip-input.tsx +3 -15
- package/src/components/selection-wizard.tsx +1 -3
- package/src/index.ts +0 -9
- package/src/components/list-item.tsx +0 -39
- package/src/components/message-header.render.test.ts +0 -58
- package/src/components/message-header.stories.tsx +0 -79
- package/src/components/message-header.tsx +0 -59
package/package.json
CHANGED
|
@@ -17,7 +17,6 @@ export interface AutoMovedBadgeProps {
|
|
|
17
17
|
* way to reach the filter that keeps moving mail. Omit for a classifier move.
|
|
18
18
|
*/
|
|
19
19
|
filtersHref?: string;
|
|
20
|
-
manageLabel?: string;
|
|
21
20
|
className?: string;
|
|
22
21
|
}
|
|
23
22
|
|
|
@@ -34,7 +33,6 @@ export function AutoMovedBadge({
|
|
|
34
33
|
onUndo,
|
|
35
34
|
undoLabel = "Undo",
|
|
36
35
|
filtersHref,
|
|
37
|
-
manageLabel = "Manage filter",
|
|
38
36
|
className,
|
|
39
37
|
}: AutoMovedBadgeProps) {
|
|
40
38
|
return (
|
|
@@ -61,7 +59,7 @@ export function AutoMovedBadge({
|
|
|
61
59
|
}}
|
|
62
60
|
className="font-semibold underline decoration-dotted underline-offset-2 hover:decoration-solid"
|
|
63
61
|
>
|
|
64
|
-
|
|
62
|
+
Manage filter
|
|
65
63
|
</a>
|
|
66
64
|
)}
|
|
67
65
|
</Badge>
|
|
@@ -75,17 +75,6 @@ export interface BriefSectionsProps {
|
|
|
75
75
|
onSelectSource?: (id: string) => void;
|
|
76
76
|
/** Seeds the filter panel open on first render (stories / deep links). */
|
|
77
77
|
defaultExpanded?: boolean;
|
|
78
|
-
/**
|
|
79
|
-
* Whether this component owns arrow-key traversal and the roving tabindex
|
|
80
|
-
* over its rows. On by default so a consumer that only passes rows (the
|
|
81
|
-
* Storybook prototype) still has a keyboard.
|
|
82
|
-
*
|
|
83
|
-
* The web client turns it off: its triage layer routes ↑/↓/Home/End through
|
|
84
|
-
* one window-level dispatcher, and a container handler here would swallow
|
|
85
|
-
* them first — taking Shift+↑/↓ with them, which the dispatcher needs for
|
|
86
|
-
* range selection and `rovingNextIndex` ignores.
|
|
87
|
-
*/
|
|
88
|
-
manageFocus?: boolean;
|
|
89
78
|
}
|
|
90
79
|
|
|
91
80
|
/**
|
|
@@ -107,7 +96,6 @@ export function BriefSections({
|
|
|
107
96
|
sourcesNote,
|
|
108
97
|
onSelectSource,
|
|
109
98
|
defaultExpanded = false,
|
|
110
|
-
manageFocus = true,
|
|
111
99
|
}: BriefSectionsProps) {
|
|
112
100
|
const [active, setActive] = useState<ReadonlySet<BriefFilterId>>(new Set());
|
|
113
101
|
const [sheetExpanded, setSheetExpanded] = useState(defaultExpanded);
|
|
@@ -115,7 +103,6 @@ export function BriefSections({
|
|
|
115
103
|
useRovingFocus({
|
|
116
104
|
containerRef: listRef,
|
|
117
105
|
itemSelector: LIST_ROW_SELECTOR,
|
|
118
|
-
enabled: manageFocus,
|
|
119
106
|
});
|
|
120
107
|
|
|
121
108
|
const toggleFilter = (id: BriefFilterId) => {
|
|
@@ -5,8 +5,6 @@ export interface DialogProps {
|
|
|
5
5
|
open: boolean;
|
|
6
6
|
onClose: () => void;
|
|
7
7
|
title: string;
|
|
8
|
-
/** Unique ID for aria-labelledby. Defaults to "dialog-title". */
|
|
9
|
-
titleId?: string;
|
|
10
8
|
children?: ReactNode;
|
|
11
9
|
className?: string;
|
|
12
10
|
/**
|
|
@@ -22,7 +20,6 @@ export function Dialog({
|
|
|
22
20
|
open,
|
|
23
21
|
onClose,
|
|
24
22
|
title,
|
|
25
|
-
titleId = "dialog-title",
|
|
26
23
|
children,
|
|
27
24
|
className,
|
|
28
25
|
anchor = "center",
|
|
@@ -86,7 +83,7 @@ export function Dialog({
|
|
|
86
83
|
ref={dialogRef}
|
|
87
84
|
role="dialog"
|
|
88
85
|
aria-modal="true"
|
|
89
|
-
aria-labelledby=
|
|
86
|
+
aria-labelledby="dialog-title"
|
|
90
87
|
className={cn(
|
|
91
88
|
"relative z-10 overflow-hidden border-line bg-surface shadow-xl",
|
|
92
89
|
isLeft
|
|
@@ -99,7 +96,7 @@ export function Dialog({
|
|
|
99
96
|
onClick={(e) => e.stopPropagation()}
|
|
100
97
|
onKeyDown={(e) => e.stopPropagation()}
|
|
101
98
|
>
|
|
102
|
-
<h2 id=
|
|
99
|
+
<h2 id="dialog-title" className="sr-only">
|
|
103
100
|
{title}
|
|
104
101
|
</h2>
|
|
105
102
|
{children}
|
|
@@ -7,9 +7,7 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
} from "react";
|
|
9
9
|
import { isAbortError } from "../lib/abort.js";
|
|
10
|
-
import { BottomSheet } from "./bottom-sheet.js";
|
|
11
10
|
import { Button } from "./button.js";
|
|
12
|
-
import { Dialog } from "./dialog.js";
|
|
13
11
|
import {
|
|
14
12
|
AddChipButton,
|
|
15
13
|
ClauseChip,
|
|
@@ -699,44 +697,3 @@ export function FilterRuleEditor({
|
|
|
699
697
|
</div>
|
|
700
698
|
);
|
|
701
699
|
}
|
|
702
|
-
|
|
703
|
-
export interface FilterRuleDialogProps extends FilterRuleEditorProps {
|
|
704
|
-
open: boolean;
|
|
705
|
-
onClose: () => void;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/** Desktop home for the rule editor — the centered modal (RFC 038 D1). */
|
|
709
|
-
export function FilterRuleDialog({
|
|
710
|
-
open,
|
|
711
|
-
onClose,
|
|
712
|
-
...editor
|
|
713
|
-
}: FilterRuleDialogProps) {
|
|
714
|
-
if (!open) return null;
|
|
715
|
-
return (
|
|
716
|
-
<Dialog open={open} onClose={onClose} title="Filter rule">
|
|
717
|
-
<FilterRuleEditor {...editor} onCancel={onClose} />
|
|
718
|
-
</Dialog>
|
|
719
|
-
);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
export interface FilterRuleSheetProps extends FilterRuleEditorProps {
|
|
723
|
-
open: boolean;
|
|
724
|
-
onClose: () => void;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/** Mobile home for the rule editor — the bottom sheet (RFC 038 D1). */
|
|
728
|
-
export function FilterRuleSheet({
|
|
729
|
-
open,
|
|
730
|
-
onClose,
|
|
731
|
-
...editor
|
|
732
|
-
}: FilterRuleSheetProps): ReactNode {
|
|
733
|
-
return (
|
|
734
|
-
<BottomSheet
|
|
735
|
-
open={open}
|
|
736
|
-
onClose={onClose}
|
|
737
|
-
dismissLabel="Dismiss filter rule"
|
|
738
|
-
>
|
|
739
|
-
<FilterRuleEditor {...editor} onCancel={onClose} />
|
|
740
|
-
</BottomSheet>
|
|
741
|
-
);
|
|
742
|
-
}
|
|
@@ -31,10 +31,8 @@ import {
|
|
|
31
31
|
widenChipLabel,
|
|
32
32
|
} from "./filter-rule.js";
|
|
33
33
|
import {
|
|
34
|
-
FilterRuleDialog,
|
|
35
34
|
FilterRuleEditor,
|
|
36
35
|
type FilterRuleEditorProps,
|
|
37
|
-
FilterRuleSheet,
|
|
38
36
|
} from "./filter-rule-editor.js";
|
|
39
37
|
|
|
40
38
|
/** SSR splits interpolations with comment markers; sentences read across them. */
|
|
@@ -671,50 +669,3 @@ describe("FilterRuleEditor", () => {
|
|
|
671
669
|
assert.doesNotMatch(html, /…and similar/);
|
|
672
670
|
});
|
|
673
671
|
});
|
|
674
|
-
|
|
675
|
-
describe("FilterRuleDialog", () => {
|
|
676
|
-
it("renders the editor when open", () => {
|
|
677
|
-
const html = render(
|
|
678
|
-
createElement(FilterRuleDialog, {
|
|
679
|
-
open: true,
|
|
680
|
-
onClose: () => {},
|
|
681
|
-
rule: demoRule,
|
|
682
|
-
folders: FOLDERS,
|
|
683
|
-
preview: READY,
|
|
684
|
-
}),
|
|
685
|
-
);
|
|
686
|
-
assert.match(html, /role="dialog"/);
|
|
687
|
-
assert.match(html, /Filter rule/);
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
it("renders nothing when closed", () => {
|
|
691
|
-
assert.equal(
|
|
692
|
-
renderToString(
|
|
693
|
-
createElement(FilterRuleDialog, {
|
|
694
|
-
open: false,
|
|
695
|
-
onClose: () => {},
|
|
696
|
-
rule: demoRule,
|
|
697
|
-
folders: FOLDERS,
|
|
698
|
-
preview: READY,
|
|
699
|
-
}),
|
|
700
|
-
),
|
|
701
|
-
"",
|
|
702
|
-
);
|
|
703
|
-
});
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
describe("FilterRuleSheet", () => {
|
|
707
|
-
it("renders the editor inside a dismissible sheet", () => {
|
|
708
|
-
const html = render(
|
|
709
|
-
createElement(FilterRuleSheet, {
|
|
710
|
-
open: true,
|
|
711
|
-
onClose: () => {},
|
|
712
|
-
rule: demoRule,
|
|
713
|
-
folders: FOLDERS,
|
|
714
|
-
preview: READY,
|
|
715
|
-
}),
|
|
716
|
-
);
|
|
717
|
-
assert.match(html, /Dismiss filter rule/);
|
|
718
|
-
assert.match(html, /Filter rule/);
|
|
719
|
-
});
|
|
720
|
-
});
|
|
@@ -158,6 +158,31 @@ const typeName = async (value: string) => {
|
|
|
158
158
|
});
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
+
const filterField = (): HTMLInputElement | null =>
|
|
162
|
+
container.querySelector('input[type="search"]');
|
|
163
|
+
|
|
164
|
+
const typeFilter = async (value: string) => {
|
|
165
|
+
const input = filterField();
|
|
166
|
+
assert.ok(input, "filter field not rendered");
|
|
167
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
168
|
+
dom.window.HTMLInputElement.prototype,
|
|
169
|
+
"value",
|
|
170
|
+
)?.set;
|
|
171
|
+
await act(async () => {
|
|
172
|
+
setter?.call(input, value);
|
|
173
|
+
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const press = async (target: Element | null | undefined, key: string) => {
|
|
178
|
+
assert.ok(target, "control not rendered");
|
|
179
|
+
await act(async () => {
|
|
180
|
+
target.dispatchEvent(
|
|
181
|
+
new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
|
|
161
186
|
const open = async (...labels: string[]) => {
|
|
162
187
|
for (const label of labels) {
|
|
163
188
|
await click(byAriaLabel(`Move to ${label}`));
|
|
@@ -529,32 +554,131 @@ describe("create wait", () => {
|
|
|
529
554
|
});
|
|
530
555
|
});
|
|
531
556
|
|
|
532
|
-
describe("
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
assert.ok(input);
|
|
539
|
-
const setter = Object.getOwnPropertyDescriptor(
|
|
540
|
-
dom.window.HTMLInputElement.prototype,
|
|
541
|
-
"value",
|
|
542
|
-
)?.set;
|
|
543
|
-
await act(async () => {
|
|
544
|
-
setter?.call(input, value);
|
|
545
|
-
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
557
|
+
describe("the form's keys stay in the form", () => {
|
|
558
|
+
it("takes a space in the name without acting on the tree", async () => {
|
|
559
|
+
const selected: string[] = [];
|
|
560
|
+
await mount({
|
|
561
|
+
onSelect: (id) => selected.push(id),
|
|
562
|
+
onCreateFolder: resolving,
|
|
546
563
|
});
|
|
564
|
+
await open("Travel");
|
|
565
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
566
|
+
await press(nameField(), " ");
|
|
567
|
+
assert.deepEqual(selected, ["travel"]);
|
|
568
|
+
assert.ok(byAriaLabel("Move to Hotels"));
|
|
569
|
+
assert.ok(createBlockOf("travel")?.querySelector("input"));
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("closes the form on Escape and leaves the picker open", async () => {
|
|
573
|
+
let cancelled = 0;
|
|
574
|
+
await mount({
|
|
575
|
+
onCancel: () => {
|
|
576
|
+
cancelled += 1;
|
|
577
|
+
},
|
|
578
|
+
onCreateFolder: resolving,
|
|
579
|
+
});
|
|
580
|
+
await open("Travel");
|
|
581
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
582
|
+
await press(nameField(), "Escape");
|
|
583
|
+
assert.equal(nameField(), null);
|
|
584
|
+
assert.equal(cancelled, 0);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe("a draft outlives the rows under it", () => {
|
|
589
|
+
const startDraft = async (props: PickerProps) => {
|
|
590
|
+
await mount(props);
|
|
591
|
+
await open("Travel");
|
|
592
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
593
|
+
await typeName("Car hire");
|
|
547
594
|
};
|
|
548
595
|
|
|
549
|
-
|
|
550
|
-
|
|
596
|
+
it("keeps the form and the typed name when another folder opens", async () => {
|
|
597
|
+
await startDraft({ onCreateFolder: resolving });
|
|
598
|
+
await open("Archive");
|
|
599
|
+
assert.equal(nameField()?.value, "Car hire");
|
|
600
|
+
assert.ok(createBlockOf("top")?.querySelector("input"));
|
|
601
|
+
assert.match(container.textContent ?? "", /Inside\s*Travel/);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("keeps the form when its own folder is closed again", async () => {
|
|
605
|
+
await startDraft({ onCreateFolder: resolving });
|
|
606
|
+
await open("Travel");
|
|
607
|
+
assert.equal(byAriaLabel("Move to Hotels"), null);
|
|
608
|
+
assert.equal(nameField()?.value, "Car hire");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("keeps the form when the filter empties the list", async () => {
|
|
612
|
+
await startDraft({ onCreateFolder: resolving });
|
|
613
|
+
await typeFilter("zzz");
|
|
614
|
+
assert.match(container.textContent ?? "", /No folders match "zzz"/);
|
|
615
|
+
assert.equal(nameField()?.value, "Car hire");
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("puts the form back among the children when its folder opens again", async () => {
|
|
619
|
+
await startDraft({ onCreateFolder: resolving });
|
|
620
|
+
await open("Archive");
|
|
621
|
+
await open("Travel");
|
|
622
|
+
assert.ok(createBlockOf("travel")?.querySelector("input"));
|
|
623
|
+
assert.equal(createBlockOf("top")?.querySelector("input"), null);
|
|
624
|
+
assert.equal(nameField()?.value, "Car hire");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("states a failure that lands after the folder it was opened in closed", async () => {
|
|
628
|
+
let fail: ((error: Error) => void) | undefined;
|
|
629
|
+
await startDraft({
|
|
630
|
+
onCreateFolder: () =>
|
|
631
|
+
new Promise<FolderTreeNode>((_resolve, reject) => {
|
|
632
|
+
fail = reject;
|
|
633
|
+
}),
|
|
634
|
+
});
|
|
635
|
+
await click(byText("Create folder"));
|
|
636
|
+
await open("Archive");
|
|
637
|
+
assert.ok(byText("Creating folder…"), "the wait went off screen");
|
|
551
638
|
await act(async () => {
|
|
552
|
-
|
|
553
|
-
new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
554
|
-
);
|
|
639
|
+
fail?.(new Error("The mail server refused that name."));
|
|
555
640
|
});
|
|
556
|
-
|
|
641
|
+
assert.equal(
|
|
642
|
+
container.querySelector('[role="alert"]')?.textContent,
|
|
643
|
+
"The mail server refused that name.",
|
|
644
|
+
);
|
|
645
|
+
assert.equal(nameField()?.value, "Car hire");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("keeps the wait on screen when the filter clears the list", async () => {
|
|
649
|
+
await startDraft({
|
|
650
|
+
onCreateFolder: () => new Promise<FolderTreeNode>(() => undefined),
|
|
651
|
+
});
|
|
652
|
+
await click(byText("Create folder"));
|
|
653
|
+
await typeFilter("zzz");
|
|
654
|
+
assert.ok(byText("Creating folder…"), "the wait went off screen");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("selects what comes back from a form the list moved under", async () => {
|
|
658
|
+
const selected: string[] = [];
|
|
659
|
+
let confirm: ((folder: FolderTreeNode) => void) | undefined;
|
|
660
|
+
await mount({
|
|
661
|
+
onSelect: (id) => selected.push(id),
|
|
662
|
+
onCreateFolder: () =>
|
|
663
|
+
new Promise<FolderTreeNode>((resolve) => {
|
|
664
|
+
confirm = resolve;
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
await open("Travel");
|
|
668
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
669
|
+
await typeName("Car hire");
|
|
670
|
+
await click(byText("Create folder"));
|
|
671
|
+
await open("Archive");
|
|
672
|
+
assert.ok(byText("Creating folder…"), "the wait went off screen");
|
|
673
|
+
await act(async () => {
|
|
674
|
+
confirm?.(created("Car hire", "Travel"));
|
|
675
|
+
});
|
|
676
|
+
assert.deepEqual(selected, ["travel", "archive", "made"]);
|
|
677
|
+
assert.equal(nameField(), null);
|
|
678
|
+
});
|
|
679
|
+
});
|
|
557
680
|
|
|
681
|
+
describe("filter and keyboard", () => {
|
|
558
682
|
const focused = (): string | null =>
|
|
559
683
|
dom.window.document.activeElement?.getAttribute("aria-label") ?? null;
|
|
560
684
|
|
|
@@ -587,6 +711,12 @@ describe("filter and keyboard", () => {
|
|
|
587
711
|
assert.match(container.textContent ?? "", /No folders match "zzz"/);
|
|
588
712
|
});
|
|
589
713
|
|
|
714
|
+
it("says the list is empty rather than blaming the filter", async () => {
|
|
715
|
+
await mount({ folders: [] });
|
|
716
|
+
await typeFilter("zzz");
|
|
717
|
+
assert.match(container.textContent ?? "", /No folders to show/);
|
|
718
|
+
});
|
|
719
|
+
|
|
590
720
|
it("picks and opens the focused row on Enter", async () => {
|
|
591
721
|
const selected: string[] = [];
|
|
592
722
|
await mount({ onSelect: (id) => selected.push(id) });
|
|
@@ -93,9 +93,10 @@ describe("FolderTreePicker render", () => {
|
|
|
93
93
|
);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
it("
|
|
96
|
+
it("says the list is empty when there is nothing to list", () => {
|
|
97
97
|
const html = render({ folders: [] });
|
|
98
|
-
assert.match(html, /No folders
|
|
98
|
+
assert.match(html, /No folders to show/);
|
|
99
|
+
assert.doesNotMatch(html, /No folders match/);
|
|
99
100
|
});
|
|
100
101
|
|
|
101
102
|
it("applies caller-supplied labels", () => {
|
|
@@ -160,6 +160,7 @@ export const LongList: Story = {
|
|
|
160
160
|
render: () => <Picker options={longFolders} />,
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
+
/** An account with nothing to list: the message states that, not a filter. */
|
|
163
164
|
export const Empty: Story = {
|
|
164
165
|
name: "No folders",
|
|
165
166
|
render: () => <Picker options={[]} />,
|
|
@@ -309,6 +310,46 @@ export const CreateFailed: Story = {
|
|
|
309
310
|
},
|
|
310
311
|
};
|
|
311
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Looking somewhere else does not throw the draft away: opening another folder
|
|
315
|
+
* closes the branch the form was in, and the form is pinned above the tree with
|
|
316
|
+
* the typed name and the folder it will be made in.
|
|
317
|
+
*/
|
|
318
|
+
export const CreateWhileTheListMoves: Story = {
|
|
319
|
+
name: "Create — kept while the list moves",
|
|
320
|
+
render: () => <Picker />,
|
|
321
|
+
play: async ({ canvasElement }) => {
|
|
322
|
+
await expand(canvasElement, "Travel");
|
|
323
|
+
clickAriaLabel(canvasElement, "New folder inside Travel");
|
|
324
|
+
await tick();
|
|
325
|
+
typeFolderName(canvasElement, "Car hire");
|
|
326
|
+
await tick();
|
|
327
|
+
await expand(canvasElement, "Finance");
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/** A failure is stated where the user is looking, whatever the list has done. */
|
|
332
|
+
export const CreateFailedAfterTheListMoved: Story = {
|
|
333
|
+
name: "Create — failed after the list moved",
|
|
334
|
+
render: () => (
|
|
335
|
+
<Picker
|
|
336
|
+
onCreateFolder={rejects(
|
|
337
|
+
"The mail server refused the folder name. Try another one.",
|
|
338
|
+
)}
|
|
339
|
+
/>
|
|
340
|
+
),
|
|
341
|
+
play: async ({ canvasElement }) => {
|
|
342
|
+
await expand(canvasElement, "Travel");
|
|
343
|
+
clickAriaLabel(canvasElement, "New folder inside Travel");
|
|
344
|
+
await tick();
|
|
345
|
+
typeFolderName(canvasElement, "Car hire");
|
|
346
|
+
await tick();
|
|
347
|
+
clickText(canvasElement, "Create folder");
|
|
348
|
+
await tick();
|
|
349
|
+
await expand(canvasElement, "Finance");
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
|
|
312
353
|
const wizardSteps: StepId[] = ["match", "folder", "rule", "review"];
|
|
313
354
|
|
|
314
355
|
function WizardFolderStep() {
|
|
@@ -53,6 +53,8 @@ export interface FolderTreePickerLabels {
|
|
|
53
53
|
/** Suffix announced for an ancestor held on screen by a match below it. */
|
|
54
54
|
contextSuffix?: string;
|
|
55
55
|
emptyMessage?: (query: string) => string;
|
|
56
|
+
/** Shown when there is no folder to list at all, filter or no filter. */
|
|
57
|
+
noFolders?: string;
|
|
56
58
|
/** Accessible label for a selectable row, e.g. `Move to X`. */
|
|
57
59
|
optionLabel?: (label: string) => string;
|
|
58
60
|
newFolder?: string;
|
|
@@ -110,6 +112,7 @@ const defaultLabels: Required<FolderTreePickerLabels> = {
|
|
|
110
112
|
currentTag: "current",
|
|
111
113
|
contextSuffix: "(containing folder)",
|
|
112
114
|
emptyMessage: (query) => `No folders match "${query}"`,
|
|
115
|
+
noFolders: "No folders to show",
|
|
113
116
|
optionLabel: (label) => `Move to ${label}`,
|
|
114
117
|
newFolder: "New folder",
|
|
115
118
|
newSubfolder: (label) => `New folder inside ${label}`,
|
|
@@ -282,6 +285,11 @@ export const FolderTreePicker = ({
|
|
|
282
285
|
|
|
283
286
|
const handleTreeKeyDown = useCallback(
|
|
284
287
|
(event: ReactKeyboardEvent<HTMLElement>) => {
|
|
288
|
+
// The create form sits inside the tree, so what is typed into its name
|
|
289
|
+
// field reaches here too. Only a row's own keys move the tree.
|
|
290
|
+
const source = event.target;
|
|
291
|
+
if (!(source instanceof Element)) return;
|
|
292
|
+
if (!source.closest('[role="treeitem"]')) return;
|
|
285
293
|
const move = (next: number) => {
|
|
286
294
|
event.preventDefault();
|
|
287
295
|
roving.current = true;
|
|
@@ -337,6 +345,14 @@ export const FolderTreePicker = ({
|
|
|
337
345
|
[rows, focusedIndex, delimiter, activateRow, setExpanded, onCancel],
|
|
338
346
|
);
|
|
339
347
|
|
|
348
|
+
const draftAnchorOnScreen =
|
|
349
|
+
draft !== null &&
|
|
350
|
+
draft.anchorId !== null &&
|
|
351
|
+
(displayRows?.some(
|
|
352
|
+
(entry) => entry.kind === "create" && entry.parent.id === draft.anchorId,
|
|
353
|
+
) ??
|
|
354
|
+
false);
|
|
355
|
+
|
|
340
356
|
const draftForm = draft && (
|
|
341
357
|
<NewFolderForm
|
|
342
358
|
parentLabel={draft.parentLabel}
|
|
@@ -414,8 +430,10 @@ export const FolderTreePicker = ({
|
|
|
414
430
|
/>
|
|
415
431
|
|
|
416
432
|
{onCreateFolder && (
|
|
433
|
+
// Closing or filtering away the folder a draft was opened in leaves
|
|
434
|
+
// the form here instead of unmounting it mid-type or mid-create.
|
|
417
435
|
<div className="shrink-0 border-b border-line" data-create-anchor="top">
|
|
418
|
-
{draft
|
|
436
|
+
{draft && !draftAnchorOnScreen ? (
|
|
419
437
|
draftForm
|
|
420
438
|
) : (
|
|
421
439
|
<NewFolderAction
|
|
@@ -430,7 +448,7 @@ export const FolderTreePicker = ({
|
|
|
430
448
|
|
|
431
449
|
{rows.length === 0 ? (
|
|
432
450
|
<p className="px-3 py-3 text-sm text-fg-muted" aria-live="polite">
|
|
433
|
-
{text.emptyMessage(query)}
|
|
451
|
+
{folders.length === 0 ? text.noFolders : text.emptyMessage(query)}
|
|
434
452
|
</p>
|
|
435
453
|
) : (
|
|
436
454
|
// A flattened tree: `aria-level` carries the nesting the indentation
|
|
@@ -6,7 +6,6 @@ import { Button } from "./button.js";
|
|
|
6
6
|
import { Card, CardBody, CardHeader, CardTitle } from "./card.js";
|
|
7
7
|
import { FieldLabel } from "./field-label.js";
|
|
8
8
|
import { Input } from "./input.js";
|
|
9
|
-
import { ListItem } from "./list-item.js";
|
|
10
9
|
import { PasswordInput } from "./password-input.js";
|
|
11
10
|
import { SecuritySelect } from "./security-select.js";
|
|
12
11
|
import { Select } from "./select.js";
|
|
@@ -176,32 +175,3 @@ export const Cards: Story = {
|
|
|
176
175
|
</div>
|
|
177
176
|
),
|
|
178
177
|
};
|
|
179
|
-
|
|
180
|
-
export const ListItems: Story = {
|
|
181
|
-
render: () => (
|
|
182
|
-
<div className="max-w-md divide-y divide-line p-8">
|
|
183
|
-
<ListItem
|
|
184
|
-
unread
|
|
185
|
-
leading={<Avatar name="Priya Natarajan" size="md" />}
|
|
186
|
-
trailing="08:52"
|
|
187
|
-
>
|
|
188
|
-
<div className="text-sm font-semibold text-fg">Priya Natarajan</div>
|
|
189
|
-
<div className="text-sm text-fg">Q3 roadmap review</div>
|
|
190
|
-
<p className="line-clamp-1 text-xs text-fg-subtle">
|
|
191
|
-
Sharing the agenda ahead of Thursday…
|
|
192
|
-
</p>
|
|
193
|
-
</ListItem>
|
|
194
|
-
<ListItem
|
|
195
|
-
active
|
|
196
|
-
leading={<Avatar name="Marcus Webb" size="md" />}
|
|
197
|
-
trailing="Wed"
|
|
198
|
-
>
|
|
199
|
-
<div className="text-sm font-medium text-fg-muted">Marcus Webb</div>
|
|
200
|
-
<div className="text-sm text-fg-muted">Re: Reading pane density</div>
|
|
201
|
-
<p className="line-clamp-1 text-xs text-fg-subtle">
|
|
202
|
-
Strong +1 on tightening the rows…
|
|
203
|
-
</p>
|
|
204
|
-
</ListItem>
|
|
205
|
-
</div>
|
|
206
|
-
),
|
|
207
|
-
};
|
|
@@ -65,8 +65,6 @@ export interface SearchChipInputProps {
|
|
|
65
65
|
*/
|
|
66
66
|
chips?: readonly SearchChip[];
|
|
67
67
|
onRemoveChip?: (id: string) => void;
|
|
68
|
-
/** Opens a chip's own value editor, where the host offers one. */
|
|
69
|
-
onActivateChip?: (id: string) => void;
|
|
70
68
|
/** The free text alongside the chips. */
|
|
71
69
|
value: string;
|
|
72
70
|
onChange: (value: string) => void;
|
|
@@ -105,8 +103,6 @@ export interface SearchChipInputProps {
|
|
|
105
103
|
* when something outside needs to address the field by a stable id.
|
|
106
104
|
*/
|
|
107
105
|
inputId?: string;
|
|
108
|
-
/** Accessible name for the chip grid. */
|
|
109
|
-
chipsLabel?: string;
|
|
110
106
|
/**
|
|
111
107
|
* Completions for what is being typed. Omit for a field that offers none;
|
|
112
108
|
* see {@link SearchFieldSuggest} for what the field and the host each own.
|
|
@@ -165,7 +161,6 @@ const isEditableTarget = (target: EventTarget | null): boolean => {
|
|
|
165
161
|
export const SearchChipInput = ({
|
|
166
162
|
chips = [],
|
|
167
163
|
onRemoveChip,
|
|
168
|
-
onActivateChip,
|
|
169
164
|
value,
|
|
170
165
|
onChange,
|
|
171
166
|
onClear,
|
|
@@ -175,7 +170,6 @@ export const SearchChipInput = ({
|
|
|
175
170
|
showClearButton = true,
|
|
176
171
|
size = "sm",
|
|
177
172
|
inputId,
|
|
178
|
-
chipsLabel = "Search filters",
|
|
179
173
|
suggest,
|
|
180
174
|
className,
|
|
181
175
|
}: SearchChipInputProps) => {
|
|
@@ -313,16 +307,13 @@ export const SearchChipInput = ({
|
|
|
313
307
|
case "focusInput":
|
|
314
308
|
moveFocus(null);
|
|
315
309
|
return;
|
|
316
|
-
case "activateChip":
|
|
317
|
-
const chip = chips[action.index];
|
|
318
|
-
if (chip && onActivateChip) onActivateChip(chip.id);
|
|
310
|
+
case "activateChip":
|
|
319
311
|
return;
|
|
320
|
-
}
|
|
321
312
|
case "none":
|
|
322
313
|
return;
|
|
323
314
|
}
|
|
324
315
|
},
|
|
325
|
-
[chips, removeChipAt, moveFocus
|
|
316
|
+
[chips, removeChipAt, moveFocus],
|
|
326
317
|
);
|
|
327
318
|
|
|
328
319
|
useEffect(() => {
|
|
@@ -371,7 +362,7 @@ export const SearchChipInput = ({
|
|
|
371
362
|
// biome-ignore lint/a11y/useSemanticElements: see above
|
|
372
363
|
<div
|
|
373
364
|
role="grid"
|
|
374
|
-
aria-label=
|
|
365
|
+
aria-label="Search filters"
|
|
375
366
|
className="flex min-w-0 flex-wrap items-center gap-1"
|
|
376
367
|
>
|
|
377
368
|
{chips.map((chip, index) => (
|
|
@@ -386,9 +377,6 @@ export const SearchChipInput = ({
|
|
|
386
377
|
onFocusLabel={() => setFocusedChip(index)}
|
|
387
378
|
onKeyDown={handleChipKeyDown(index)}
|
|
388
379
|
onRemove={() => removeChipAt(index)}
|
|
389
|
-
onActivate={
|
|
390
|
-
onActivateChip ? () => onActivateChip(chip.id) : undefined
|
|
391
|
-
}
|
|
392
380
|
/>
|
|
393
381
|
))}
|
|
394
382
|
</div>
|
|
@@ -351,7 +351,6 @@ export function SelectionSample({
|
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
export interface FooterNavProps {
|
|
354
|
-
backLabel?: string;
|
|
355
354
|
onBack: () => void;
|
|
356
355
|
nextLabel: string;
|
|
357
356
|
onNext: () => void;
|
|
@@ -369,7 +368,6 @@ export interface FooterNavProps {
|
|
|
369
368
|
* carries the two ways that reason reaches the user.
|
|
370
369
|
*/
|
|
371
370
|
export function FooterNav({
|
|
372
|
-
backLabel = "Back",
|
|
373
371
|
onBack,
|
|
374
372
|
nextLabel,
|
|
375
373
|
onNext,
|
|
@@ -396,7 +394,7 @@ export function FooterNav({
|
|
|
396
394
|
icon={<ArrowLeft className="size-4" />}
|
|
397
395
|
className="shrink-0"
|
|
398
396
|
>
|
|
399
|
-
|
|
397
|
+
Back
|
|
400
398
|
</Button>
|
|
401
399
|
<Button
|
|
402
400
|
variant={nextVariant}
|
package/src/index.ts
CHANGED
|
@@ -184,12 +184,8 @@ export {
|
|
|
184
184
|
} from "./components/filter-rule.js";
|
|
185
185
|
export {
|
|
186
186
|
type ClauseEditState,
|
|
187
|
-
FilterRuleDialog,
|
|
188
|
-
type FilterRuleDialogProps,
|
|
189
187
|
FilterRuleEditor,
|
|
190
188
|
type FilterRuleEditorProps,
|
|
191
|
-
FilterRuleSheet,
|
|
192
|
-
type FilterRuleSheetProps,
|
|
193
189
|
} from "./components/filter-rule-editor.js";
|
|
194
190
|
export {
|
|
195
191
|
FilterSheet,
|
|
@@ -273,7 +269,6 @@ export {
|
|
|
273
269
|
type LabelChipData,
|
|
274
270
|
type LabelChipProps,
|
|
275
271
|
} from "./components/label-chip.js";
|
|
276
|
-
export { ListItem, type ListItemProps } from "./components/list-item.js";
|
|
277
272
|
export {
|
|
278
273
|
type MailAction,
|
|
279
274
|
MailActionToolbar,
|
|
@@ -288,10 +283,6 @@ export {
|
|
|
288
283
|
MessageBodyView,
|
|
289
284
|
type MessageBodyViewProps,
|
|
290
285
|
} from "./components/message-body-view.js";
|
|
291
|
-
export {
|
|
292
|
-
MessageHeader,
|
|
293
|
-
type MessageHeaderProps,
|
|
294
|
-
} from "./components/message-header.js";
|
|
295
286
|
export { MessageListPane } from "./components/message-list-pane.js";
|
|
296
287
|
export {
|
|
297
288
|
type FilterReach,
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
-
import { cn } from "../lib/cn.js";
|
|
3
|
-
|
|
4
|
-
export interface ListItemProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
-
leading?: ReactNode;
|
|
6
|
-
trailing?: ReactNode;
|
|
7
|
-
active?: boolean;
|
|
8
|
-
unread?: boolean;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function ListItem({
|
|
12
|
-
leading,
|
|
13
|
-
trailing,
|
|
14
|
-
active,
|
|
15
|
-
unread,
|
|
16
|
-
className,
|
|
17
|
-
children,
|
|
18
|
-
...props
|
|
19
|
-
}: ListItemProps) {
|
|
20
|
-
return (
|
|
21
|
-
<div
|
|
22
|
-
className={cn(
|
|
23
|
-
"group relative flex cursor-pointer items-start gap-3 px-4 py-3 transition-colors",
|
|
24
|
-
active ? "bg-accent-2-soft" : "hover:bg-surface-sunken",
|
|
25
|
-
className,
|
|
26
|
-
)}
|
|
27
|
-
{...props}
|
|
28
|
-
>
|
|
29
|
-
{unread && (
|
|
30
|
-
<span className="absolute left-1.5 top-1/2 size-1.5 -translate-y-1/2 rounded-full bg-accent" />
|
|
31
|
-
)}
|
|
32
|
-
{leading && <div className="shrink-0 pt-0.5">{leading}</div>}
|
|
33
|
-
<div className="min-w-0 flex-1">{children}</div>
|
|
34
|
-
{trailing && (
|
|
35
|
-
<div className="shrink-0 text-2xs text-fg-subtle">{trailing}</div>
|
|
36
|
-
)}
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
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 { EnvelopeAddress } from "./address-display.js";
|
|
6
|
-
import { MessageHeader, type MessageHeaderProps } from "./message-header.js";
|
|
7
|
-
|
|
8
|
-
const ada: EnvelopeAddress = {
|
|
9
|
-
displayName: "Ada Lovelace",
|
|
10
|
-
normalizedEmail: "ada@example.com",
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const base: MessageHeaderProps = {
|
|
14
|
-
subject: "Quarterly numbers",
|
|
15
|
-
from: [ada],
|
|
16
|
-
to: [{ normalizedEmail: "team@example.com" }],
|
|
17
|
-
date: "Mon, 23 Jun 2026, 14:00",
|
|
18
|
-
senderTrust: "wellknown",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
describe("MessageHeader", () => {
|
|
22
|
-
it("renders subject, addresses and the formatted date", () => {
|
|
23
|
-
const html = renderToString(createElement(MessageHeader, base));
|
|
24
|
-
assert.match(html, /Quarterly numbers/);
|
|
25
|
-
assert.match(html, /Ada Lovelace/);
|
|
26
|
-
assert.match(html, /team@example.com/);
|
|
27
|
-
assert.match(html, /Mon, 23 Jun 2026, 14:00/);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("shows a fallback for a missing subject", () => {
|
|
31
|
-
const html = renderToString(
|
|
32
|
-
createElement(MessageHeader, { ...base, subject: undefined }),
|
|
33
|
-
);
|
|
34
|
-
assert.match(html, /\(No subject\)/);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("renders the category badge and trust indicator", () => {
|
|
38
|
-
const html = renderToString(
|
|
39
|
-
createElement(MessageHeader, {
|
|
40
|
-
...base,
|
|
41
|
-
category: "newsletter",
|
|
42
|
-
senderTrust: "vip",
|
|
43
|
-
}),
|
|
44
|
-
);
|
|
45
|
-
assert.match(html, /aria-label="Category: newsletter"/);
|
|
46
|
-
assert.match(html, /aria-label="VIP sender"/);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("renders an actions slot when provided", () => {
|
|
50
|
-
const html = renderToString(
|
|
51
|
-
createElement(MessageHeader, {
|
|
52
|
-
...base,
|
|
53
|
-
actions: createElement("button", { type: "button" }, "Menu"),
|
|
54
|
-
}),
|
|
55
|
-
);
|
|
56
|
-
assert.match(html, /Menu/);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Menu } from "lucide-react";
|
|
3
|
-
import type { EnvelopeAddress } from "./address-display.js";
|
|
4
|
-
import { MessageHeader } from "./message-header.js";
|
|
5
|
-
|
|
6
|
-
const meta: Meta<typeof MessageHeader> = {
|
|
7
|
-
title: "Mail/MessageHeader",
|
|
8
|
-
component: MessageHeader,
|
|
9
|
-
parameters: { layout: "fullscreen" },
|
|
10
|
-
};
|
|
11
|
-
export default meta;
|
|
12
|
-
|
|
13
|
-
type Story = StoryObj<typeof MessageHeader>;
|
|
14
|
-
|
|
15
|
-
const named = (name: string, email: string): EnvelopeAddress => ({
|
|
16
|
-
displayName: name,
|
|
17
|
-
normalizedEmail: email,
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const trustedFrom: EnvelopeAddress = {
|
|
21
|
-
...named("Ada Lovelace", "ada@example.com"),
|
|
22
|
-
flags: { trusted: { value: true } },
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const date = "Mon, 23 Jun 2026, 14:00";
|
|
26
|
-
|
|
27
|
-
export const TrustedSender: Story = {
|
|
28
|
-
args: {
|
|
29
|
-
subject: "Quarterly numbers are in",
|
|
30
|
-
from: [trustedFrom],
|
|
31
|
-
to: [named("The team", "team@example.com")],
|
|
32
|
-
date,
|
|
33
|
-
senderTrust: "wellknown",
|
|
34
|
-
},
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export const NewSenderNewsletter: Story = {
|
|
38
|
-
name: "New sender + newsletter",
|
|
39
|
-
args: {
|
|
40
|
-
subject: "Welcome to the weekly digest",
|
|
41
|
-
from: [named("Digest", "hello@digest.example.com")],
|
|
42
|
-
to: [{ normalizedEmail: "you@example.com" }],
|
|
43
|
-
date,
|
|
44
|
-
category: "newsletter",
|
|
45
|
-
senderTrust: "unknown",
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
export const VipWithManyRecipients: Story = {
|
|
50
|
-
name: "VIP + many recipients",
|
|
51
|
-
args: {
|
|
52
|
-
subject: "Board meeting follow-up",
|
|
53
|
-
from: [{ ...named("Grace Hopper", "grace@example.com") }],
|
|
54
|
-
to: [
|
|
55
|
-
named("Alan Turing", "alan@example.com"),
|
|
56
|
-
named("Katherine Johnson", "katherine@example.com"),
|
|
57
|
-
named("Dorothy Vaughan", "dorothy@example.com"),
|
|
58
|
-
named("Mary Jackson", "mary@example.com"),
|
|
59
|
-
],
|
|
60
|
-
cc: [named("Margaret Hamilton", "margaret@example.com")],
|
|
61
|
-
date,
|
|
62
|
-
senderTrust: "vip",
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
export const NoSubjectWithActions: Story = {
|
|
67
|
-
name: "No subject + actions slot",
|
|
68
|
-
args: {
|
|
69
|
-
from: [named("Someone", "someone@example.com")],
|
|
70
|
-
to: [{ normalizedEmail: "you@example.com" }],
|
|
71
|
-
date,
|
|
72
|
-
senderTrust: "wellknown",
|
|
73
|
-
actions: (
|
|
74
|
-
<button type="button" aria-label="Menu" className="text-fg-muted">
|
|
75
|
-
<Menu className="size-5" />
|
|
76
|
-
</button>
|
|
77
|
-
),
|
|
78
|
-
},
|
|
79
|
-
};
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
|
-
import { AddressList, type EnvelopeAddress } from "./address-display.js";
|
|
3
|
-
import { CategoryBadge, type MessageCategory } from "./category-badge.js";
|
|
4
|
-
import {
|
|
5
|
-
type SenderTrust,
|
|
6
|
-
SenderTrustIndicator,
|
|
7
|
-
} from "./sender-trust-indicator.js";
|
|
8
|
-
|
|
9
|
-
export interface MessageHeaderProps {
|
|
10
|
-
subject?: string;
|
|
11
|
-
from: EnvelopeAddress[];
|
|
12
|
-
to: EnvelopeAddress[];
|
|
13
|
-
cc?: EnvelopeAddress[];
|
|
14
|
-
/** Pre-formatted, human-readable date. The consumer owns formatting. */
|
|
15
|
-
date: string;
|
|
16
|
-
category?: MessageCategory;
|
|
17
|
-
senderTrust: SenderTrust;
|
|
18
|
-
/**
|
|
19
|
-
* Optional slot for surfaces (hamburger menu, etc) rendered inline on the
|
|
20
|
-
* right of the subject line. Kept generic so the header doesn't need to
|
|
21
|
-
* know what action set is in play.
|
|
22
|
-
*/
|
|
23
|
-
actions?: ReactNode;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const MessageHeader = ({
|
|
27
|
-
subject,
|
|
28
|
-
from,
|
|
29
|
-
to,
|
|
30
|
-
cc = [],
|
|
31
|
-
date,
|
|
32
|
-
category,
|
|
33
|
-
senderTrust,
|
|
34
|
-
actions,
|
|
35
|
-
}: MessageHeaderProps) => {
|
|
36
|
-
return (
|
|
37
|
-
<div className="border-b border-line p-4">
|
|
38
|
-
<div className="flex items-start justify-between gap-2 mb-3">
|
|
39
|
-
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
40
|
-
<h1 className="text-xl font-semibold truncate">
|
|
41
|
-
{subject || "(No subject)"}
|
|
42
|
-
</h1>
|
|
43
|
-
<CategoryBadge category={category} size="md" />
|
|
44
|
-
<SenderTrustIndicator senderTrust={senderTrust} size="md" />
|
|
45
|
-
</div>
|
|
46
|
-
{actions && <div className="shrink-0">{actions}</div>}
|
|
47
|
-
</div>
|
|
48
|
-
<div className="space-y-1">
|
|
49
|
-
<AddressList label="From" addresses={from} showTrustedBadge />
|
|
50
|
-
<AddressList label="To" addresses={to} />
|
|
51
|
-
{cc.length > 0 && <AddressList label="Cc" addresses={cc} />}
|
|
52
|
-
<div className="flex gap-2 text-sm">
|
|
53
|
-
<span className="text-fg-muted shrink-0 w-12">Date:</span>
|
|
54
|
-
<span className="text-fg">{date}</span>
|
|
55
|
-
</div>
|
|
56
|
-
</div>
|
|
57
|
-
</div>
|
|
58
|
-
);
|
|
59
|
-
};
|