@remit/ui 0.0.5 → 0.0.7

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.5",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -105,6 +105,37 @@ describe("AppShellSlotted slot rendering", () => {
105
105
  );
106
106
  });
107
107
 
108
+ it("renders the top bar above the panes, at every width", () => {
109
+ const topBarEl = createElement("div", { "data-testid": "topbar" }, "Bar");
110
+ for (const initialWidth of [800, 1100, 1400]) {
111
+ const html = render({ initialWidth, topBar: topBarEl });
112
+ assert.match(
113
+ html,
114
+ /data-testid="topbar"/,
115
+ `top bar at ${initialWidth}px`,
116
+ );
117
+ assert.ok(
118
+ html.indexOf('data-testid="topbar"') <
119
+ html.indexOf('data-testid="list"'),
120
+ `top bar precedes the pane group at ${initialWidth}px`,
121
+ );
122
+ }
123
+ });
124
+
125
+ it("omits the top bar when the caller passes none", () => {
126
+ const html = render({ initialWidth: 1100 });
127
+ assert.doesNotMatch(html, /data-testid="topbar"/);
128
+ });
129
+
130
+ it("hides the top bar with the rest of the layout while loading", () => {
131
+ const html = render({
132
+ isLoading: true,
133
+ skeleton: createElement("div", { "data-testid": "skeleton" }, "Loading"),
134
+ topBar: createElement("div", { "data-testid": "topbar" }, "Bar"),
135
+ });
136
+ assert.doesNotMatch(html, /data-testid="topbar"/);
137
+ });
138
+
108
139
  it("renders the overlay regardless of width", () => {
109
140
  const overlayEl = createElement("div", { "data-testid": "overlay" }, "FAB");
110
141
  const narrowHtml = render({ initialWidth: 800, overlay: overlayEl });
@@ -59,6 +59,13 @@ export interface AppShellSlottedProps {
59
59
  * The web-client injects the app-specific bar (hamburger / title / search).
60
60
  */
61
61
  header?: ReactNode;
62
+ /**
63
+ * Full-width row above every pane, including the nav. Unlike `header` it is
64
+ * rendered at whatever width the caller mounts it, and it spans the whole
65
+ * shell rather than sitting inside a pane — that width is what makes the
66
+ * search field in it read as the app's search rather than the list's.
67
+ */
68
+ topBar?: ReactNode;
62
69
  /**
63
70
  * Content rendered outside the pane group (e.g., the compose FAB). Floats
64
71
  * over the layout regardless of width.
@@ -130,6 +137,7 @@ export function AppShellSlotted({
130
137
  hasThread = true,
131
138
  density = "comfortable",
132
139
  header,
140
+ topBar,
133
141
  overlay,
134
142
  skeleton,
135
143
  isLoading = false,
@@ -178,6 +186,8 @@ export function AppShellSlotted({
178
186
  skeleton
179
187
  ) : (
180
188
  <>
189
+ {topBar}
190
+
181
191
  {/* Narrow top bar: rendered only when the nav is a slide-over
182
192
  (< 1024px). Desktop has no slim bar. */}
183
193
  {!showNavPane && header && <div className="shrink-0">{header}</div>}
@@ -161,6 +161,7 @@ export interface NavLinkRenderProps {
161
161
  export type NavLinkComponent = (props: NavLinkRenderProps) => ReactElement;
162
162
 
163
163
  export type ThreadCategory =
164
+ | "uncategorized"
164
165
  | "personal"
165
166
  | "newsletter"
166
167
  | "marketing"
@@ -182,6 +183,7 @@ export const briefCategories: ReadonlyArray<{
182
183
  }> = [
183
184
  { id: "all", label: "All" },
184
185
  { id: "personal", label: "Personal" },
186
+ { id: "uncategorized", label: "Unclassified" },
185
187
  { id: "newsletter", label: "Newsletters" },
186
188
  { id: "marketing", label: "Marketing" },
187
189
  { id: "automated", label: "Automated" },
@@ -319,6 +321,7 @@ export const categoryTone: Record<
319
321
  ThreadCategory,
320
322
  "neutral" | "accent" | "positive" | "warning"
321
323
  > = {
324
+ uncategorized: "neutral",
322
325
  personal: "accent",
323
326
  newsletter: "neutral",
324
327
  marketing: "neutral",
@@ -1,6 +1,7 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
 
3
3
  export type MessageCategory =
4
+ | "uncategorized"
4
5
  | "personal"
5
6
  | "newsletter"
6
7
  | "marketing"
@@ -15,8 +16,14 @@ export type MessageCategory =
15
16
  * so it has no entry here. `transactional` shows as "receipt" and `automated`
16
17
  * shows as "notification" — wording chosen so the badge reads naturally next
17
18
  * to a subject line.
19
+ *
20
+ * `uncategorized` does render a badge. It used to be displayed as `personal`,
21
+ * which made "the classifier never ran on this message" indistinguishable from
22
+ * "the classifier decided this is a person writing to you" — a classification
23
+ * gap presented as a full personal inbox (issue #45).
18
24
  */
19
25
  const CATEGORY_LABELS: Record<Exclude<MessageCategory, "personal">, string> = {
26
+ uncategorized: "unclassified",
20
27
  newsletter: "newsletter",
21
28
  marketing: "marketing",
22
29
  automated: "notification",
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import { createElement } from "react";
4
4
  import { renderToString } from "react-dom/server";
5
+ import { AppTopBar } from "./app-top-bar.js";
5
6
  import { MailHeader, type MailHeaderProps } from "./mail-header.js";
7
+ import { SearchBar } from "./search-bar.js";
6
8
 
7
9
  function render(overrides: Partial<MailHeaderProps> = {}): string {
8
10
  return renderToString(
@@ -67,3 +69,58 @@ describe("MailHeader", () => {
67
69
  assert.match(html, /Daily brief/);
68
70
  });
69
71
  });
72
+
73
+ /**
74
+ * One search field per page (#49).
75
+ *
76
+ * Moving search into `AppTopBar` puts a second `SearchBar` on the same screen
77
+ * as the list header's own. Two mounted fields compete for the "/" shortcut and
78
+ * for focus, and phase 1's review already found a pair colliding on a shared
79
+ * DOM id. `showSearch` is what holds the count at one, so the count is what
80
+ * these assert.
81
+ */
82
+ describe("MailHeader search ownership", () => {
83
+ const countSearchInputs = (html: string): number =>
84
+ html.match(/aria-label="Search mail"/g)?.length ?? 0;
85
+
86
+ const topBar = (): string =>
87
+ renderToString(
88
+ createElement(AppTopBar, {
89
+ search: createElement(SearchBar, {
90
+ value: "",
91
+ onChange: () => undefined,
92
+ onClear: () => undefined,
93
+ size: "lg",
94
+ }),
95
+ }),
96
+ );
97
+
98
+ it("mounts no search field, and no magnifier, when showSearch is false", () => {
99
+ const html = render({ isDesktop: true, showSearch: false });
100
+ assert.equal(countSearchInputs(html), 0);
101
+ assert.doesNotMatch(html, /aria-label="Search"/);
102
+ });
103
+
104
+ it("keeps the title and unread count when search lives elsewhere", () => {
105
+ const html = render({ isDesktop: true, showSearch: false });
106
+ assert.match(html, /Daily brief/);
107
+ assert.match(html, /15,338 unread/);
108
+ });
109
+
110
+ it("suppresses the field below desktop too, where the takeover owns search", () => {
111
+ assert.equal(
112
+ countSearchInputs(render({ isDesktop: false, showSearch: false })),
113
+ 0,
114
+ );
115
+ });
116
+
117
+ it("leaves exactly one field on screen beside the app top bar", () => {
118
+ const combined = topBar() + render({ isDesktop: true, showSearch: false });
119
+ assert.equal(countSearchInputs(combined), 1);
120
+ });
121
+
122
+ it("would be two fields if the header kept its own — the premise being guarded", () => {
123
+ const combined = topBar() + render({ isDesktop: true });
124
+ assert.equal(countSearchInputs(combined), 2);
125
+ });
126
+ });
@@ -22,6 +22,12 @@ export interface MailHeaderProps {
22
22
  /** Mobile only: whether the inline search is expanded over the title. */
23
23
  searchOpen: boolean;
24
24
  onSearchOpenChange: (open: boolean) => void;
25
+ /**
26
+ * Whether this header owns a search surface at all. Defaults to true. Set
27
+ * false where an enclosing `AppTopBar` already carries the search field, so
28
+ * the page never mounts two search inputs competing for the same focus.
29
+ */
30
+ showSearch?: boolean;
25
31
  }
26
32
 
27
33
  /**
@@ -44,6 +50,7 @@ export function MailHeader({
44
50
  onSearchClear,
45
51
  searchOpen,
46
52
  onSearchOpenChange,
53
+ showSearch = true,
47
54
  }: MailHeaderProps) {
48
55
  const unreadLabel = `${unreadCount.toLocaleString()} unread`;
49
56
  const clearSearch = onSearchClear ?? (() => onSearchChange(""));
@@ -75,7 +82,17 @@ export function MailHeader({
75
82
  return (
76
83
  <header className="flex shrink-0 flex-col bg-canvas">
77
84
  <div className="flex h-12 items-center gap-2 px-row-inset">
78
- {isDesktop ? (
85
+ {!showSearch ? (
86
+ <>
87
+ {menuButton}
88
+ <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
89
+ {title}
90
+ </h1>
91
+ <span className="shrink-0 text-2xs text-fg-subtle">
92
+ {unreadLabel}
93
+ </span>
94
+ </>
95
+ ) : isDesktop ? (
79
96
  <>
80
97
  {menuButton}
81
98
  <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
@@ -22,6 +22,7 @@
22
22
  * in fresh checkouts).
23
23
  */
24
24
  export type EmailRenderCategory =
25
+ | "uncategorized"
25
26
  | "personal"
26
27
  | "newsletter"
27
28
  | "marketing"