@remit/ui 0.0.2 → 0.0.4

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.
@@ -0,0 +1,212 @@
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
+ SearchChipInput,
7
+ type SearchChipInputProps,
8
+ } from "./search-chip-input.js";
9
+
10
+ const noop = () => {};
11
+
12
+ const render = (overrides: Partial<SearchChipInputProps> = {}): string =>
13
+ renderToString(
14
+ createElement(SearchChipInput, {
15
+ value: "",
16
+ onChange: noop,
17
+ onClear: noop,
18
+ ...overrides,
19
+ }),
20
+ );
21
+
22
+ const SPAM = { id: "in:spam", label: "in:spam" };
23
+ const FROM = { id: "from:acme", label: "from:acme" };
24
+
25
+ describe("SearchChipInput", () => {
26
+ it("renders a real text input with an accessible label", () => {
27
+ const html = render({ placeholder: "Search mail" });
28
+ assert.match(html, /aria-label="Search mail"/);
29
+ assert.match(html, /placeholder="Search mail"/);
30
+ assert.match(html, /value=""/);
31
+ });
32
+
33
+ it("renders one removable chip per narrowing term, in order", () => {
34
+ const html = render({ chips: [SPAM, FROM] });
35
+ assert.match(html, /in:spam/);
36
+ assert.match(html, /from:acme/);
37
+ assert.match(html, /aria-label="Remove filter: in:spam"/);
38
+ assert.match(html, /aria-label="Remove filter: from:acme"/);
39
+ assert.ok(
40
+ html.indexOf("in:spam") < html.indexOf("from:acme"),
41
+ "chips keep expression order",
42
+ );
43
+ });
44
+
45
+ it("keeps chips and free text in the same field", () => {
46
+ const html = render({ chips: [SPAM], value: "invoice" });
47
+ assert.match(html, /in:spam/);
48
+ assert.match(html, /value="invoice"/);
49
+ });
50
+
51
+ it("drops the placeholder once the expression carries a chip", () => {
52
+ const html = render({ chips: [SPAM], placeholder: "Search mail" });
53
+ assert.doesNotMatch(html, /placeholder="Search mail"/);
54
+ });
55
+
56
+ it("offers the clear control for a chip-only expression", () => {
57
+ // A scope with no typed text is still a narrowed search — clearing must be
58
+ // reachable without typing first.
59
+ const html = render({ chips: [SPAM] });
60
+ assert.match(html, /aria-label="Clear search"/);
61
+ });
62
+
63
+ it("omits the clear control while the expression is empty", () => {
64
+ assert.doesNotMatch(render(), /aria-label="Clear search"/);
65
+ });
66
+
67
+ it("omits the inline clear when showClearButton is false", () => {
68
+ const html = render({ value: "receipt", showClearButton: false });
69
+ assert.doesNotMatch(html, /aria-label="Clear search"/);
70
+ assert.match(html, /value="receipt"/);
71
+ });
72
+
73
+ it("carries a live region so a removal is never silent", () => {
74
+ const html = render({ chips: [SPAM] });
75
+ assert.match(html, /role="status"/);
76
+ assert.match(html, /aria-live="polite"/);
77
+ });
78
+
79
+ it("renders the same field at either size", () => {
80
+ for (const size of ["sm", "lg"] as const) {
81
+ const html = render({ size, chips: [SPAM], value: "invoice" });
82
+ assert.match(html, /aria-label="Search mail"/);
83
+ assert.match(html, /in:spam/);
84
+ }
85
+ });
86
+
87
+ it("takes a caller-supplied input id so a page can host more than one field", () => {
88
+ assert.match(render({ inputId: "top-bar-search" }), /id="top-bar-search"/);
89
+ });
90
+ });
91
+
92
+ describe("SearchChipInput chip semantics", () => {
93
+ it("groups the chips as a labelled grid of rows", () => {
94
+ const html = render({ chips: [SPAM, FROM] });
95
+ assert.match(html, /role="grid"/);
96
+ assert.match(html, /aria-label="Search filters"/);
97
+ assert.equal(html.match(/role="row"/g)?.length, 2, "one row per chip");
98
+ });
99
+
100
+ it("names the label and the remove action as separate cells", () => {
101
+ // A chip with a remove affordance has to announce both actions, not one.
102
+ const html = render({ chips: [SPAM] });
103
+ assert.equal(html.match(/role="gridcell"/g)?.length, 2);
104
+ });
105
+
106
+ it("omits the grid entirely when there are no chips", () => {
107
+ assert.doesNotMatch(render(), /role="grid"/);
108
+ });
109
+
110
+ it("keeps the text input outside the grid, as its sibling", () => {
111
+ const html = render({ chips: [SPAM] });
112
+ const gridEnd = html.indexOf("</div>", html.indexOf('role="grid"'));
113
+ assert.ok(
114
+ html.indexOf('aria-label="Search mail"') > gridEnd,
115
+ "the input renders after the grid closes",
116
+ );
117
+ });
118
+
119
+ it("holds the whole field in a single tab stop", () => {
120
+ // Exactly one thing is in the tab order — the text input, until a chip
121
+ // takes it over. Everything else is reached by the arrow/backspace route,
122
+ // so Tab never walks through the chips one at a time.
123
+ const html = render({ chips: [SPAM, FROM] });
124
+ assert.equal(
125
+ html.match(/tabindex="0"/gi)?.length,
126
+ 1,
127
+ "one tab stop for the whole field",
128
+ );
129
+ assert.ok((html.match(/tabindex="-1"/gi)?.length ?? 0) >= 6);
130
+ });
131
+
132
+ it("differentiates a scope chip from a typed filter chip", () => {
133
+ const scoped = render({ chips: [{ ...SPAM, tone: "scope" }] });
134
+ const filter = render({ chips: [SPAM] });
135
+ assert.notEqual(scoped, filter);
136
+ assert.match(scoped, /accent-2/);
137
+ });
138
+ });
139
+
140
+ describe("Two fields mounted at once stay independent", () => {
141
+ // The field wraps itself in <label for>, and `for` binds to the first
142
+ // matching id in tree order. A shared default id would therefore point the
143
+ // second field's label at the first field's input — clicking one bar's
144
+ // padding would focus the other. The desktop layout mounts two at once.
145
+ const renderPair = (): string =>
146
+ renderToString(
147
+ createElement(
148
+ "div",
149
+ null,
150
+ createElement(SearchChipInput, {
151
+ value: "",
152
+ onChange: noop,
153
+ onClear: noop,
154
+ }),
155
+ createElement(SearchChipInput, {
156
+ value: "",
157
+ onChange: noop,
158
+ onClear: noop,
159
+ }),
160
+ ),
161
+ );
162
+
163
+ it("gives each field its own input id", () => {
164
+ const ids = [...renderPair().matchAll(/<input[^>]*\sid="([^"]+)"/g)].map(
165
+ (m) => m[1],
166
+ );
167
+ assert.equal(ids.length, 2);
168
+ assert.notEqual(ids[0], ids[1], "two fields must not share an input id");
169
+ });
170
+
171
+ it("points each label at its own input", () => {
172
+ const html = renderPair();
173
+ const labelTargets = [...html.matchAll(/<label[^>]*\sfor="([^"]+)"/g)].map(
174
+ (m) => m[1],
175
+ );
176
+ const inputIds = [...html.matchAll(/<input[^>]*\sid="([^"]+)"/g)].map(
177
+ (m) => m[1],
178
+ );
179
+ assert.deepEqual(labelTargets, inputIds);
180
+ });
181
+
182
+ it("still honours an explicit id when the caller needs a stable one", () => {
183
+ const html = renderToString(
184
+ createElement(SearchChipInput, {
185
+ value: "",
186
+ onChange: noop,
187
+ onClear: noop,
188
+ inputId: "top-bar-search",
189
+ }),
190
+ );
191
+ assert.match(html, /<label[^>]*for="top-bar-search"/);
192
+ assert.match(html, /<input[^>]*id="top-bar-search"/);
193
+ });
194
+ });
195
+
196
+ describe("A read-only chip strip promises nothing it cannot do", () => {
197
+ it("still renders the remove control so the strip looks the same", () => {
198
+ // Removal is host-owned. Without a handler the chip cannot go anywhere, so
199
+ // the field must not announce a removal or move focus as though it had —
200
+ // see the guard in removeChipAt.
201
+ const html = render({ chips: [SPAM] });
202
+ assert.match(html, /aria-label="Remove filter: in:spam"/);
203
+ });
204
+
205
+ it("starts with an empty live region rather than a stale announcement", () => {
206
+ const html = render({ chips: [SPAM], onRemoveChip: noop });
207
+ assert.match(
208
+ html,
209
+ /role="status"[^>]*><\/span>|role="status"[^>]*>\s*<\/span>/,
210
+ );
211
+ });
212
+ });
@@ -0,0 +1,171 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { type SearchChip, SearchChipInput } from "./search-chip-input.js";
4
+
5
+ const meta: Meta<typeof SearchChipInput> = {
6
+ title: "Mail/SearchChipInput",
7
+ component: SearchChipInput,
8
+ parameters: { layout: "padded" },
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof SearchChipInput>;
13
+
14
+ /**
15
+ * The field does not create chips — the host commits them from structured
16
+ * intent (the view being navigated to, a filter menu, a suggestion). The "Add
17
+ * filter" buttons below stand in for that host, so the chip lifecycle is
18
+ * exercisable here in isolation.
19
+ */
20
+ const Interactive = ({
21
+ initialChips = [],
22
+ initialValue = "",
23
+ size = "sm",
24
+ offer = [],
25
+ }: {
26
+ initialChips?: SearchChip[];
27
+ initialValue?: string;
28
+ size?: "sm" | "lg";
29
+ offer?: SearchChip[];
30
+ }) => {
31
+ const [chips, setChips] = useState<SearchChip[]>(initialChips);
32
+ const [value, setValue] = useState(initialValue);
33
+ const unused = offer.filter(
34
+ (c) => !chips.some((existing) => existing.id === c.id),
35
+ );
36
+
37
+ return (
38
+ <div className="flex w-full max-w-2xl flex-col gap-3">
39
+ <SearchChipInput
40
+ chips={chips}
41
+ onRemoveChip={(id) => setChips((cs) => cs.filter((c) => c.id !== id))}
42
+ value={value}
43
+ onChange={setValue}
44
+ onClear={() => {
45
+ setValue("");
46
+ setChips([]);
47
+ }}
48
+ onClearQuery={() => setValue("")}
49
+ globalFocusKey={false}
50
+ size={size}
51
+ />
52
+ {unused.length > 0 && (
53
+ <div className="flex flex-wrap items-center gap-2">
54
+ <span className="text-2xs text-fg-subtle">Add filter:</span>
55
+ {unused.map((chip) => (
56
+ <button
57
+ key={chip.id}
58
+ type="button"
59
+ onClick={() => setChips((cs) => [...cs, chip])}
60
+ className="rounded-full border border-line px-2 py-0.5 text-2xs text-fg-muted hover:bg-surface"
61
+ >
62
+ {chip.label}
63
+ </button>
64
+ ))}
65
+ </div>
66
+ )}
67
+ <p className="max-w-prose text-2xs leading-relaxed text-fg-subtle">
68
+ One tab stop. From the text: Backspace or ArrowLeft at the very start
69
+ moves onto the last chip, Shift+Tab steps back into the chips. On a
70
+ chip: Backspace or Delete removes it, Left/Right walk the chips,
71
+ ArrowRight past the last one returns to the text. After a removal focus
72
+ lands on the chip that took its place, else the previous one, else the
73
+ text.
74
+ </p>
75
+ </div>
76
+ );
77
+ };
78
+
79
+ const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
80
+
81
+ const OFFER: SearchChip[] = [
82
+ { id: "from:acme", label: "from:acme" },
83
+ { id: "has:attachment", label: "has:attachment" },
84
+ { id: "is:unread", label: "is:unread" },
85
+ { id: "before:2026-01-01", label: "before:2026-01-01" },
86
+ ];
87
+
88
+ /** Unscoped — the daily brief's state: no chips, search reads across everything. */
89
+ export const Unscoped: Story = {
90
+ render: () => <Interactive offer={[SCOPE, ...OFFER]} />,
91
+ };
92
+
93
+ /**
94
+ * One narrowing chip: the view the user navigated into. A scope carries a
95
+ * different tint from a filter the user added, because it came from where they
96
+ * are rather than from something they typed.
97
+ */
98
+ export const OneChip: Story = {
99
+ render: () => <Interactive initialChips={[SCOPE]} offer={OFFER} />,
100
+ };
101
+
102
+ /** A chip and free text together — one expression, read left to right. */
103
+ export const ChipWithText: Story = {
104
+ render: () => (
105
+ <Interactive initialChips={[SCOPE]} initialValue="invoice" offer={OFFER} />
106
+ ),
107
+ };
108
+
109
+ /** Several chips: they wrap onto the next line rather than clipping. */
110
+ export const MultipleChips: Story = {
111
+ render: () => (
112
+ <Interactive
113
+ initialChips={[
114
+ SCOPE,
115
+ { id: "from:acme", label: "from:acme" },
116
+ { id: "has:attachment", label: "has:attachment" },
117
+ { id: "before:2026-01-01", label: "before:2026-01-01" },
118
+ ]}
119
+ initialValue="refund"
120
+ offer={OFFER}
121
+ />
122
+ ),
123
+ };
124
+
125
+ /**
126
+ * A typed operator stays plain text. Chipping only what the product committed
127
+ * keeps the typed query honest — the text is exactly what the user typed, and
128
+ * the field never has to guess what was meant as an operator.
129
+ */
130
+ export const TypedOperatorStaysText: Story = {
131
+ render: () => (
132
+ <Interactive
133
+ initialChips={[SCOPE]}
134
+ initialValue="from:bob receipt"
135
+ offer={OFFER}
136
+ />
137
+ ),
138
+ };
139
+
140
+ /** A long chip label truncates; its remove control stays reachable. */
141
+ export const LongChipLabel: Story = {
142
+ render: () => (
143
+ <Interactive
144
+ initialChips={[
145
+ {
146
+ id: "from:long",
147
+ label: "from:notifications-noreply@some-very-long-domain.example.com",
148
+ },
149
+ ]}
150
+ offer={OFFER}
151
+ />
152
+ ),
153
+ };
154
+
155
+ /** The taller field the global top bar uses. */
156
+ export const LargeForTopBar: Story = {
157
+ render: () => <Interactive size="lg" initialChips={[SCOPE]} offer={OFFER} />,
158
+ };
159
+
160
+ /**
161
+ * Focus resting on a chip — the state the first Backspace leaves the field in,
162
+ * from which a second Backspace removes it.
163
+ */
164
+ export const ChipFocused: Story = {
165
+ render: () => <Interactive initialChips={[SCOPE]} offer={OFFER} />,
166
+ play: async ({ canvasElement }) => {
167
+ canvasElement
168
+ .querySelector<HTMLButtonElement>('[role="row"] button')
169
+ ?.focus();
170
+ },
171
+ };