@remit/ui 0.0.6 → 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.6",
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>}
@@ -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">