@remit/ui 0.0.78 → 0.0.79

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.78",
3
+ "version": "0.0.79",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,11 +1,13 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { Bug, SquarePen } from "lucide-react";
3
2
  import { useState } from "react";
4
3
  import { AppTopBar } from "./app-top-bar.js";
5
- import { Avatar } from "./avatar.js";
6
- import { Button } from "./button.js";
7
- import { type SearchChip, SearchChipInput } from "./search-chip-input.js";
4
+ import { SearchChipInput } from "./search-chip-input.js";
8
5
 
6
+ /**
7
+ * The bar's geometry. What fills it — which actions, in what order, with what
8
+ * wording — is `ShellTopBar`, which is what the app and the shell prototype
9
+ * both mount; these stories show only the row the slots sit in.
10
+ */
9
11
  const meta: Meta<typeof AppTopBar> = {
10
12
  title: "Mail/AppTopBar",
11
13
  component: AppTopBar,
@@ -15,169 +17,64 @@ export default meta;
15
17
 
16
18
  type Story = StoryObj<typeof AppTopBar>;
17
19
 
18
- const Actions = () => (
19
- <>
20
- <Button
21
- variant="ghost"
22
- size="sm"
23
- icon={<SquarePen className="size-4" />}
24
- title="Compose"
25
- aria-label="Compose"
26
- />
27
- <Button
28
- variant="ghost"
29
- size="sm"
30
- icon={<Bug className="size-4" />}
31
- title="Report a bug"
32
- aria-label="Report a bug"
33
- />
34
- <Avatar name="Matthijs van Henten" email="mvh@example.com" size="sm" />
35
- </>
20
+ const Slot = ({ label }: { label: string }) => (
21
+ <div className="rounded border border-dashed border-line px-2 py-1 text-2xs text-fg-subtle">
22
+ {label}
23
+ </div>
36
24
  );
37
25
 
38
- const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
39
-
40
- /**
41
- * The placeholders the app pairs with each scope state. Only the unscoped brief
42
- * may claim to search all mail; a scoped view says so, and a mailbox route
43
- * whose name has not loaded yet gets neutral wording rather than a placeholder
44
- * asserting the wrong scope.
45
- */
46
- const PLACEHOLDER = {
47
- global: "Search all mail",
48
- pending: "Search mail",
49
- scoped: "Search this folder",
50
- } as const;
51
-
52
- const Bar = ({
53
- initialChips = [],
54
- placeholder = PLACEHOLDER.global,
55
- }: {
56
- initialChips?: SearchChip[];
57
- placeholder?: string;
58
- }) => {
59
- const [chips, setChips] = useState<SearchChip[]>(initialChips);
26
+ const Field = () => {
60
27
  const [value, setValue] = useState("");
61
28
  return (
62
- <AppTopBar
63
- actions={<Actions />}
64
- search={
65
- <SearchChipInput
66
- size="lg"
67
- chips={chips}
68
- onRemoveChip={(id) => setChips((cs) => cs.filter((c) => c.id !== id))}
69
- value={value}
70
- onChange={setValue}
71
- onClear={() => {
72
- setValue("");
73
- setChips([]);
74
- }}
75
- onClearQuery={() => setValue("")}
76
- globalFocusKey={false}
77
- placeholder={placeholder}
78
- />
79
- }
29
+ <SearchChipInput
30
+ size="lg"
31
+ value={value}
32
+ onChange={setValue}
33
+ onClear={() => setValue("")}
34
+ onClearQuery={() => setValue("")}
35
+ globalFocusKey={false}
36
+ placeholder="Search all mail"
80
37
  />
81
38
  );
82
39
  };
83
40
 
84
- /** Over the panes it spans, so the arrangement reads the way it will in the app. */
85
- const WithPanes = ({ children }: { children: React.ReactNode }) => (
86
- <div className="flex h-96 flex-col bg-canvas">
87
- {children}
88
- <div className="flex min-h-0 flex-1">
89
- <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
90
- Nav — under the bar, like every other pane
91
- </div>
92
- <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
93
- Message list
94
- </div>
95
- <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
96
- Message pane — its own toolbar lives here, under the bar
97
- </div>
98
- </div>
99
- </div>
100
- );
101
-
102
- /**
103
- * The daily brief's state: search unscoped, nothing narrowing it. No chip, and
104
- * the only placeholder allowed to claim it searches all mail — which it now
105
- * genuinely does, across every folder of every account.
106
- */
107
- export const Unscoped: Story = {
108
- render: () => <Bar />,
109
- };
110
-
111
- /**
112
- * A narrowing scope in the bar, tinted to mark it as the view the user is in
113
- * rather than a filter they typed. Removing it widens the search again — a
114
- * navigation back to the brief, not an edit of the text, because the chip
115
- * mirrors the route.
116
- *
117
- * The placeholder narrows with the chip. A scoped bar reading "Search all mail"
118
- * is a state the app never produces.
119
- */
120
- export const Scoped: Story = {
121
- render: () => <Bar initialChips={[SCOPE]} placeholder={PLACEHOLDER.scoped} />,
122
- };
123
-
124
- /**
125
- * The third scope state: a mailbox route whose name has not resolved yet. The
126
- * list underneath is already narrowed, so the bar must not claim to search
127
- * everything — but a chip reading a raw uuid is worse than no chip, so it shows
128
- * none and falls back to neutral wording until the name arrives.
129
- */
130
- export const ScopePending: Story = {
131
- render: () => <Bar placeholder={PLACEHOLDER.pending} />,
132
- };
133
-
134
- /**
135
- * The virtual collections scope the bar too, and their chips read as whatever
136
- * describes the collection. Flagged is a marker on the mail rather than a
137
- * place, so it chips `is:starred`; the outbox is a place mail sits in and keeps
138
- * the `in:` form.
139
- */
140
- export const ScopedToFlagged: Story = {
41
+ /** Leading · search · actions. The field is the only slot that grows. */
42
+ export const Slots: Story = {
141
43
  render: () => (
142
- <Bar
143
- initialChips={[{ id: "is:starred", label: "is:starred", tone: "scope" }]}
144
- placeholder={PLACEHOLDER.scoped}
44
+ <AppTopBar
45
+ leading={<Slot label="leading" />}
46
+ search={<Field />}
47
+ actions={<Slot label="actions" />}
145
48
  />
146
49
  ),
147
50
  };
148
51
 
149
- export const ScopedToOutbox: Story = {
150
- render: () => (
151
- <Bar
152
- initialChips={[{ id: "in:outbox", label: "in:outbox", tone: "scope" }]}
153
- placeholder={PLACEHOLDER.scoped}
154
- />
155
- ),
52
+ /** With nothing but the field, the bar is still the page's one search surface. */
53
+ export const SearchOnly: Story = {
54
+ render: () => <AppTopBar search={<Field />} />,
156
55
  };
157
56
 
158
- /** The arrangement: one bar across the top of the shell, over the nav, the list
159
- * and the message pane alike. */
57
+ /** One row across the top of the shell, over the nav, the list and the message
58
+ * pane alike. */
160
59
  export const OverTheLayout: Story = {
161
60
  render: () => (
162
- <WithPanes>
163
- <Bar initialChips={[SCOPE]} placeholder={PLACEHOLDER.scoped} />
164
- </WithPanes>
165
- ),
166
- };
167
-
168
- export const SearchOnly: Story = {
169
- render: () => (
170
- <AppTopBar
171
- search={
172
- <SearchChipInput
173
- size="lg"
174
- value=""
175
- onChange={() => undefined}
176
- onClear={() => undefined}
177
- globalFocusKey={false}
178
- placeholder="Search all mail"
179
- />
180
- }
181
- />
61
+ <div className="flex h-96 flex-col bg-canvas">
62
+ <AppTopBar
63
+ leading={<Slot label="leading" />}
64
+ search={<Field />}
65
+ actions={<Slot label="actions" />}
66
+ />
67
+ <div className="flex min-h-0 flex-1">
68
+ <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
69
+ Nav — under the bar, like every other pane
70
+ </div>
71
+ <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
72
+ Message list
73
+ </div>
74
+ <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
75
+ Message pane — its own toolbar lives here, under the bar
76
+ </div>
77
+ </div>
78
+ </div>
182
79
  ),
183
80
  };
@@ -0,0 +1,95 @@
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
+ ShellTopBar,
7
+ type ShellTopBarProps,
8
+ type ShellTopBarSearch,
9
+ } from "./shell-top-bar.js";
10
+
11
+ const search = (
12
+ overrides: Partial<ShellTopBarSearch> = {},
13
+ ): ShellTopBarSearch => ({
14
+ value: "",
15
+ scope: "global",
16
+ onChange: () => undefined,
17
+ onClear: () => undefined,
18
+ onClearQuery: () => undefined,
19
+ ...overrides,
20
+ });
21
+
22
+ const render = (overrides: Partial<ShellTopBarProps> = {}): string =>
23
+ renderToString(
24
+ createElement(ShellTopBar, {
25
+ search: search(),
26
+ onCompose: () => undefined,
27
+ onReportBug: () => undefined,
28
+ onOpenSettings: () => undefined,
29
+ account: createElement("div", { "data-testid": "account" }, "account"),
30
+ ...overrides,
31
+ }),
32
+ );
33
+
34
+ describe("ShellTopBar", () => {
35
+ it("carries compose, bug report, settings and the account, in that order", () => {
36
+ const html = render();
37
+ const compose = html.indexOf('aria-label="Compose"');
38
+ const bug = html.indexOf('aria-label="Report a bug"');
39
+ const settings = html.indexOf('aria-label="Settings"');
40
+ const account = html.indexOf('data-testid="account"');
41
+ assert.ok(compose > -1 && bug > -1 && settings > -1 && account > -1);
42
+ assert.ok(
43
+ compose < bug && bug < settings && settings < account,
44
+ "actions render in reading order",
45
+ );
46
+ });
47
+
48
+ it("carries exactly the four actions and nothing else", () => {
49
+ const labels = [...render().matchAll(/aria-label="([^"]+)"/g)].map(
50
+ (match) => match[1],
51
+ );
52
+ assert.deepEqual(labels, [
53
+ "Search mail",
54
+ "Compose",
55
+ "Report a bug",
56
+ "Settings",
57
+ ]);
58
+ });
59
+
60
+ it("words the compose tooltip around the host's bare key", () => {
61
+ assert.match(render({ composeShortcut: "c" }), /title="Compose \(c\)"/);
62
+ });
63
+
64
+ it("names the action alone when the host binds no key to it", () => {
65
+ assert.match(render(), /title="Compose"/);
66
+ });
67
+
68
+ it("only claims to search all mail when nothing narrows it", () => {
69
+ assert.match(render(), /placeholder="Search all mail"/);
70
+ });
71
+
72
+ it("says which folder a scoped view searches", () => {
73
+ assert.match(
74
+ render({ search: search({ scope: "scoped" }) }),
75
+ /placeholder="Search this folder"/,
76
+ );
77
+ });
78
+
79
+ it("carries the route's scope as a chip and drops the claim to search all mail", () => {
80
+ const html = render({
81
+ search: search({
82
+ scope: "scoped",
83
+ chips: [{ id: "in:spam", label: "in:spam", tone: "scope" }],
84
+ }),
85
+ });
86
+ assert.match(html, /in:spam/);
87
+ assert.doesNotMatch(html, /Search all mail/);
88
+ });
89
+
90
+ it("falls back to neutral wording while a mailbox name is still loading", () => {
91
+ const html = render({ search: search({ scope: "pending" }) });
92
+ assert.match(html, /placeholder="Search mail"/);
93
+ assert.doesNotMatch(html, /Search all mail/);
94
+ });
95
+ });
@@ -0,0 +1,132 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Avatar } from "./avatar.js";
4
+ import type { SearchChip } from "./search-chip-input.js";
5
+ import { type ShellSearchScope, ShellTopBar } from "./shell-top-bar.js";
6
+
7
+ const meta: Meta<typeof ShellTopBar> = {
8
+ title: "Mail/ShellTopBar",
9
+ component: ShellTopBar,
10
+ parameters: { layout: "fullscreen" },
11
+ };
12
+ export default meta;
13
+
14
+ type Story = StoryObj<typeof ShellTopBar>;
15
+
16
+ const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
17
+
18
+ const Account = () => (
19
+ <button type="button" aria-label="Account">
20
+ <Avatar name="Matthijs van Henten" email="mvh@example.com" size="sm" />
21
+ </button>
22
+ );
23
+
24
+ const Bar = ({
25
+ initialChips,
26
+ scope = "global",
27
+ }: {
28
+ initialChips?: SearchChip[];
29
+ scope?: ShellSearchScope;
30
+ }) => {
31
+ const [chips, setChips] = useState<SearchChip[] | undefined>(initialChips);
32
+ const [value, setValue] = useState("");
33
+ return (
34
+ <ShellTopBar
35
+ search={{
36
+ value,
37
+ scope: chips?.length ? scope : "global",
38
+ chips,
39
+ onChange: setValue,
40
+ onClear: () => {
41
+ setValue("");
42
+ setChips(undefined);
43
+ },
44
+ onClearQuery: () => setValue(""),
45
+ onRemoveChip: () => setChips(undefined),
46
+ }}
47
+ onCompose={() => undefined}
48
+ onReportBug={() => undefined}
49
+ onOpenSettings={() => undefined}
50
+ composeShortcut="c"
51
+ account={<Account />}
52
+ />
53
+ );
54
+ };
55
+
56
+ /**
57
+ * The daily brief's state: search unscoped, nothing narrowing it, and the only
58
+ * placeholder allowed to claim it searches all mail — which it genuinely does,
59
+ * across every folder of every account.
60
+ */
61
+ export const Unscoped: Story = {
62
+ render: () => <Bar />,
63
+ };
64
+
65
+ /**
66
+ * A narrowing scope in the bar, tinted to mark it as the view the user is in
67
+ * rather than a filter they typed. Removing it widens the search again — a
68
+ * navigation back to the brief, not an edit of the text, because the chip
69
+ * mirrors the route. The placeholder narrows with the chip.
70
+ */
71
+ export const Scoped: Story = {
72
+ render: () => <Bar initialChips={[SCOPE]} scope="scoped" />,
73
+ };
74
+
75
+ /**
76
+ * A mailbox route whose name has not resolved yet. The list underneath is
77
+ * already narrowed, so the bar must not claim to search everything — and a chip
78
+ * reading a raw uuid is worse than no chip, so it shows none and falls back to
79
+ * neutral wording until the name arrives.
80
+ */
81
+ export const ScopePending: Story = {
82
+ render: () => (
83
+ <ShellTopBar
84
+ search={{
85
+ value: "",
86
+ scope: "pending",
87
+ onChange: () => undefined,
88
+ onClear: () => undefined,
89
+ onClearQuery: () => undefined,
90
+ }}
91
+ onCompose={() => undefined}
92
+ onReportBug={() => undefined}
93
+ onOpenSettings={() => undefined}
94
+ composeShortcut="c"
95
+ account={<Account />}
96
+ />
97
+ ),
98
+ };
99
+
100
+ /**
101
+ * The virtual collections scope the bar too, and their chips read as whatever
102
+ * describes the collection. Flagged is a marker on the mail rather than a
103
+ * place, so it chips `is:starred`.
104
+ */
105
+ export const ScopedToFlagged: Story = {
106
+ render: () => (
107
+ <Bar
108
+ initialChips={[{ id: "is:starred", label: "is:starred", tone: "scope" }]}
109
+ scope="scoped"
110
+ />
111
+ ),
112
+ };
113
+
114
+ /** The arrangement: one bar across the top of the shell, over every pane. */
115
+ export const OverTheLayout: Story = {
116
+ render: () => (
117
+ <div className="flex h-96 flex-col bg-canvas">
118
+ <Bar initialChips={[SCOPE]} scope="scoped" />
119
+ <div className="flex min-h-0 flex-1">
120
+ <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
121
+ Nav — under the bar, like every other pane
122
+ </div>
123
+ <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
124
+ Message list
125
+ </div>
126
+ <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
127
+ Message pane — its own toolbar lives here, under the bar
128
+ </div>
129
+ </div>
130
+ </div>
131
+ ),
132
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ShellTopBar — the app's one search surface and its global actions, composed.
3
+ *
4
+ * `AppTopBar` is geometry; this is what fills it. It spans the whole layout —
5
+ * the nav column, the list, the reading pane and the intelligence rail — and
6
+ * carries the actions that belong to the app rather than to whatever is
7
+ * currently listed or open: the nav toggle, compose, bug report, settings,
8
+ * account. Reply, delete, move and the rest stay on the reading pane's own
9
+ * toolbar, under this bar.
10
+ *
11
+ * It is the app's search, not the list's: the list header drops its own field
12
+ * wherever this bar is mounted, so exactly one search input exists on the page
13
+ * and the "/" shortcut has one target.
14
+ *
15
+ * The field carries one chip: the scope of the view the user navigated into
16
+ * (`in:spam` in Spam, nothing on the brief). Removing it goes to the brief and
17
+ * searches everything. Typed `in:`/`from:` terms are not chipped here — they
18
+ * are already visible as the text the user typed, and chipping them would show
19
+ * the same term twice in one field; they render as chips over the result
20
+ * sections instead, where the text is not repeated.
21
+ *
22
+ * Which actions, in what order, with what wording lives here and only here, so
23
+ * the bar the prototype shows is the bar the app shows.
24
+ */
25
+ import { Bug, Settings, SquarePen } from "lucide-react";
26
+ import type { ReactNode } from "react";
27
+ import { AppTopBar } from "./app-top-bar.js";
28
+ import { Button } from "./button.js";
29
+ import { NavToggleButton } from "./nav-toggle-button.js";
30
+ import { SearchBar } from "./search-bar.js";
31
+ import type { SearchChip } from "./search-chip-input.js";
32
+
33
+ /**
34
+ * How narrowed the search already is. Only the unscoped brief may claim to
35
+ * search all mail; a mailbox route whose name has not loaded yet is already
36
+ * narrowed but has no chip to show, so it gets neutral wording rather than a
37
+ * placeholder that asserts the wrong scope.
38
+ */
39
+ export type ShellSearchScope = "global" | "pending" | "scoped";
40
+
41
+ const SEARCH_PLACEHOLDER: Record<ShellSearchScope, string> = {
42
+ global: "Search all mail",
43
+ pending: "Search mail",
44
+ scoped: "Search this folder",
45
+ };
46
+
47
+ export interface ShellTopBarSearch {
48
+ value: string;
49
+ scope: ShellSearchScope;
50
+ chips?: readonly SearchChip[];
51
+ onChange: (value: string) => void;
52
+ onClear: () => void;
53
+ onClearQuery: () => void;
54
+ onRemoveChip?: (id: string) => void;
55
+ }
56
+
57
+ export interface ShellTopBarProps {
58
+ search: ShellTopBarSearch;
59
+ onCompose: () => void;
60
+ onReportBug: () => void;
61
+ onOpenSettings: () => void;
62
+ /**
63
+ * The key that composes, as the host's keymap reads it — `c`, `⌘N`,
64
+ * `g then b`. The tooltip's wording around it is this component's.
65
+ */
66
+ composeShortcut?: string;
67
+ /**
68
+ * The account control at the bar's trailing edge. An element rather than
69
+ * data: the app hangs a signed-in session and its sign-out off it.
70
+ */
71
+ account: ReactNode;
72
+ }
73
+
74
+ export function ShellTopBar({
75
+ search,
76
+ onCompose,
77
+ onReportBug,
78
+ onOpenSettings,
79
+ composeShortcut,
80
+ account,
81
+ }: ShellTopBarProps) {
82
+ return (
83
+ <AppTopBar
84
+ leading={<NavToggleButton />}
85
+ search={
86
+ <SearchBar
87
+ value={search.value}
88
+ onChange={search.onChange}
89
+ onClear={search.onClear}
90
+ onClearQuery={search.onClearQuery}
91
+ chips={search.chips}
92
+ onRemoveChip={search.onRemoveChip}
93
+ placeholder={SEARCH_PLACEHOLDER[search.scope]}
94
+ />
95
+ }
96
+ actions={
97
+ <>
98
+ <Button
99
+ variant="ghost"
100
+ size="sm"
101
+ icon={<SquarePen className="size-4" />}
102
+ title={composeShortcut ? `Compose (${composeShortcut})` : "Compose"}
103
+ aria-label="Compose"
104
+ onClick={onCompose}
105
+ />
106
+ <Button
107
+ variant="ghost"
108
+ size="sm"
109
+ icon={<Bug className="size-4" />}
110
+ title="Report a bug"
111
+ aria-label="Report a bug"
112
+ onClick={onReportBug}
113
+ />
114
+ <Button
115
+ variant="ghost"
116
+ size="sm"
117
+ icon={<Settings className="size-4" />}
118
+ title="Settings"
119
+ aria-label="Settings"
120
+ onClick={onOpenSettings}
121
+ />
122
+ {account}
123
+ </>
124
+ }
125
+ />
126
+ );
127
+ }
package/src/index.ts CHANGED
@@ -582,6 +582,12 @@ export {
582
582
  SettingsShell,
583
583
  type SettingsShellProps,
584
584
  } from "./components/settings-screen.js";
585
+ export {
586
+ type ShellSearchScope,
587
+ ShellTopBar,
588
+ type ShellTopBarProps,
589
+ type ShellTopBarSearch,
590
+ } from "./components/shell-top-bar.js";
585
591
  export {
586
592
  SlidePanel,
587
593
  type SlidePanelProps,