@remit/ui 0.0.55 → 0.0.57

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/app-shell-types.ts +8 -1
  3. package/src/components/auto-moved-badge.stories.tsx +3 -21
  4. package/src/components/auto-moved-badge.tsx +8 -17
  5. package/src/components/brief-empty.stories.tsx +51 -0
  6. package/src/components/brief-empty.tsx +60 -0
  7. package/src/components/dialog.tsx +10 -5
  8. package/src/components/filter-clause-chip.tsx +39 -1
  9. package/src/components/filter-rule-editor.stories.tsx +163 -1
  10. package/src/components/filter-rule-editor.tsx +47 -0
  11. package/src/components/filter-rule.render.test.ts +33 -0
  12. package/src/components/filter-rule.ts +141 -0
  13. package/src/components/input.tsx +7 -0
  14. package/src/components/mail-header.tsx +10 -0
  15. package/src/components/message-row.tsx +41 -3
  16. package/src/components/mobile-search-view.render.test.ts +31 -0
  17. package/src/components/mobile-search-view.stories.tsx +115 -6
  18. package/src/components/mobile-search-view.tsx +34 -8
  19. package/src/components/password-input.render.test.ts +149 -0
  20. package/src/components/password-input.tsx +45 -0
  21. package/src/components/primitives.stories.tsx +28 -0
  22. package/src/components/search-bar.tsx +9 -0
  23. package/src/components/search-chip-input.tsx +85 -3
  24. package/src/components/search-result-row.tsx +68 -82
  25. package/src/components/search-results.stories.tsx +12 -0
  26. package/src/components/search-results.tsx +20 -8
  27. package/src/components/suggest-list.render.test.ts +59 -0
  28. package/src/components/suggest-list.tsx +96 -0
  29. package/src/components/swipeable-row.gesture.test.ts +128 -2
  30. package/src/components/swipeable-row.stories.tsx +54 -0
  31. package/src/components/swipeable-row.tsx +60 -31
  32. package/src/index.ts +37 -0
  33. package/src/lib/suggest-keys.test.ts +101 -0
  34. package/src/lib/suggest-keys.ts +63 -0
  35. package/src/lib/use-long-press.ts +70 -37
  36. package/src/lib/use-suggest-list.test.ts +169 -0
  37. package/src/lib/use-suggest-list.ts +124 -0
@@ -5,10 +5,12 @@ import {
5
5
  type FilterAccount,
6
6
  inboxFilterConfig,
7
7
  } from "../filter-presets.js";
8
+ import { useSuggestList } from "../lib/use-suggest-list.js";
8
9
  import { MobileSearchView } from "./mobile-search-view.js";
9
10
  import type { SearchChip } from "./search-chip-input.js";
10
11
  import type { SearchResult } from "./search-result-row.js";
11
12
  import type { SearchResultSection, SearchScope } from "./search-results.js";
13
+ import { type Suggestion, SuggestList } from "./suggest-list.js";
12
14
 
13
15
  const phoneFrame: Decorator = (Story) => (
14
16
  <div
@@ -30,6 +32,7 @@ const topMatches: SearchResult[] = [
30
32
  {
31
33
  id: "r1",
32
34
  sender: "Stripe",
35
+ senderEmail: "receipts@stripe.com",
33
36
  subject: "Your invoice for March is ready",
34
37
  snippet: "Invoice #4821 — €149.00 paid on Visa ending 4242.",
35
38
  date: "9:42",
@@ -39,6 +42,7 @@ const topMatches: SearchResult[] = [
39
42
  {
40
43
  id: "r2",
41
44
  sender: "Hetzner Online",
45
+ senderEmail: "billing@hetzner.com",
42
46
  subject: "Invoice 2026-03 available in your account",
43
47
  snippet: "Dear customer, your invoice for the period is attached.",
44
48
  date: "Mar 3",
@@ -47,6 +51,7 @@ const topMatches: SearchResult[] = [
47
51
  {
48
52
  id: "r3",
49
53
  sender: "Anna de Vries",
54
+ senderEmail: "anna@devries.nl",
50
55
  subject: "Re: Q1 invoice approval",
51
56
  snippet: "Approved — can you forward the PDF invoice to finance?",
52
57
  date: "Mar 1",
@@ -55,6 +60,7 @@ const topMatches: SearchResult[] = [
55
60
  {
56
61
  id: "r4",
57
62
  sender: "AWS Billing",
63
+ senderEmail: "no-reply@aws.amazon.com",
58
64
  subject: "Your invoice is now available",
59
65
  snippet: "Your total for February was $312.55 across 6 services.",
60
66
  date: "Feb 28",
@@ -104,6 +110,7 @@ const crossFolderMatches: SearchResult[] = [
104
110
  {
105
111
  id: "x1",
106
112
  sender: "Mollie",
113
+ senderEmail: "info@mollie.com",
107
114
  subject: "Invoice 2026-02 — archived",
108
115
  snippet: "Filed last month; payment already settled.",
109
116
  date: "Feb 24",
@@ -112,6 +119,7 @@ const crossFolderMatches: SearchResult[] = [
112
119
  {
113
120
  id: "x2",
114
121
  sender: "Accountant",
122
+ senderEmail: "jan@boekhouding.example",
115
123
  subject: "Invoices for the quarter",
116
124
  snippet: "The quarterly set, filed with the rest of the bookkeeping.",
117
125
  date: "Jan 30",
@@ -124,6 +132,7 @@ const spamMatches: SearchResult[] = [
124
132
  {
125
133
  id: "s1",
126
134
  sender: "billing@unknown-vendor.test",
135
+ senderEmail: "billing@unknown-vendor.test",
127
136
  subject: "URGENT invoice attached",
128
137
  snippet: "Wire the amount below within 24 hours to avoid suspension.",
129
138
  date: "Feb 11",
@@ -132,6 +141,7 @@ const spamMatches: SearchResult[] = [
132
141
  {
133
142
  id: "s2",
134
143
  sender: "invoices@pay-now.test",
144
+ senderEmail: "invoices@pay-now.test",
135
145
  subject: "Outstanding invoice — final notice",
136
146
  snippet: "Your account is overdue. Settle immediately.",
137
147
  date: "Feb 4",
@@ -147,6 +157,26 @@ const acrossFoldersSections: SearchResultSection[] = [
147
157
  },
148
158
  ];
149
159
 
160
+ /** The folders `in:` can name, as the app's search vocabulary offers them. */
161
+ const folderSuggestions: Suggestion[] = [
162
+ { value: "in:Archive", label: "Archive", hint: "matthijs@ischen.nl" },
163
+ { value: "in:Inbox", label: "Inbox", hint: "matthijs@ischen.nl" },
164
+ { value: 'in:"Sent Items"', label: "Sent Items", hint: "work@acme.test" },
165
+ { value: "in:Spam", label: "Spam", hint: "matthijs@ischen.nl" },
166
+ ];
167
+
168
+ /**
169
+ * Replace the last term with the completion. The app does this over the caret
170
+ * and the parsed query (`lib/search-suggestions.ts`); the harness keeps the
171
+ * shape so a story shows what picking a row does.
172
+ */
173
+ const completeTerm = (value: string, suggestion: Suggestion): string => {
174
+ const head = value.slice(0, value.search(/\S*$/));
175
+ return suggestion.value.endsWith(":")
176
+ ? head + suggestion.value
177
+ : `${head + suggestion.value} `;
178
+ };
179
+
150
180
  type Preset = "brief" | "inbox";
151
181
 
152
182
  function Harness({
@@ -156,6 +186,8 @@ function Harness({
156
186
  sections,
157
187
  preset,
158
188
  scope,
189
+ makeFilterDisabledReason,
190
+ suggestions = [],
159
191
  }: {
160
192
  initialValue?: string;
161
193
  initialChips?: SearchChip[];
@@ -163,6 +195,10 @@ function Harness({
163
195
  sections?: SearchResultSection[];
164
196
  preset: Preset;
165
197
  scope?: SearchScope;
198
+ /** Renders the conversion inert with a reason; it is offered either way. */
199
+ makeFilterDisabledReason?: string;
200
+ /** Completions for the term being typed. */
201
+ suggestions?: Suggestion[];
166
202
  }) {
167
203
  const [value, setValue] = useState(initialValue);
168
204
  const [chips, setChips] = useState<SearchChip[]>(initialChips);
@@ -172,6 +208,14 @@ function Harness({
172
208
  const [expanded, setExpanded] = useState(false);
173
209
  const [opened, setOpened] = useState<SearchResult | null>(null);
174
210
 
211
+ const suggest = useSuggestList({
212
+ count: suggestions.length,
213
+ onAccept: (index) => {
214
+ const suggestion = suggestions[index];
215
+ if (suggestion) setValue((current) => completeTerm(current, suggestion));
216
+ },
217
+ });
218
+
175
219
  const base =
176
220
  preset === "brief"
177
221
  ? briefFilterConfig(
@@ -236,6 +280,31 @@ function Harness({
236
280
  loading={loading}
237
281
  onSelectResult={setOpened}
238
282
  scope={scope}
283
+ makeFilter={{
284
+ onClick: () => undefined,
285
+ disabledReason: makeFilterDisabledReason,
286
+ }}
287
+ suggest={{
288
+ comboboxProps: suggest.comboboxProps,
289
+ onKeyDown: suggest.handleKeyDown,
290
+ onCaretChange: () => undefined,
291
+ }}
292
+ suggestList={
293
+ suggest.open ? (
294
+ <SuggestList
295
+ id={suggest.listId}
296
+ suggestions={suggestions}
297
+ activeIndex={suggest.activeIndex}
298
+ optionId={suggest.optionId}
299
+ onPick={(suggestion) =>
300
+ setValue((current) => completeTerm(current, suggestion))
301
+ }
302
+ onHighlight={suggest.setActiveIndex}
303
+ label="Search suggestions"
304
+ className="mx-row-inset mt-1 shrink-0"
305
+ />
306
+ ) : null
307
+ }
239
308
  />
240
309
  );
241
310
  }
@@ -251,9 +320,10 @@ export default meta;
251
320
  type Story = StoryObj<typeof MobileSearchView>;
252
321
 
253
322
  /**
254
- * Global search — the daily-brief preset, so the FilterSheet carries the account
255
- * source row (matthijs@ / work@acme) on top of the shared categories + filters.
256
- * The header carries a single X that clears the query AND dismisses the takeover.
323
+ * Global search — the daily-brief preset. The filter chrome is not up: a query
324
+ * supersedes it, and the row above the results belongs to the conversion the
325
+ * search itself offers. The header carries a single X that clears the query AND
326
+ * dismisses the takeover.
257
327
  */
258
328
  export const GlobalSearch: Story = {
259
329
  render: () => (
@@ -262,8 +332,9 @@ export const GlobalSearch: Story = {
262
332
  };
263
333
 
264
334
  /**
265
- * Scoped search — a single inbox, so the inbox preset drops the account row
266
- * entirely (the view is already scoped); same categories and filters otherwise.
335
+ * Scoped search — a single inbox. It reads exactly like the global one above:
336
+ * search is scoped by where the user is and differs in nothing else, so the
337
+ * conversion is offered here too.
267
338
  */
268
339
  export const ScopedSearch: Story = {
269
340
  render: () => (
@@ -271,7 +342,45 @@ export const ScopedSearch: Story = {
271
342
  ),
272
343
  };
273
344
 
274
- /** Empty query: recent searches under the brief filter chrome. */
345
+ /**
346
+ * A query with nothing a filter could match on: the conversion is offered inert
347
+ * with the reason, rather than withheld and leaving the row to appear and vanish
348
+ * as the user types.
349
+ */
350
+ export const NothingToConvert: Story = {
351
+ render: () => (
352
+ <Harness
353
+ initialValue="has:attachment"
354
+ sections={resultSections}
355
+ preset="inbox"
356
+ makeFilterDisabledReason="Add a sender or words to filter on"
357
+ />
358
+ ),
359
+ };
360
+
361
+ /**
362
+ * A committed token name: the folders `in:` can name, offered under the field.
363
+ *
364
+ * The list takes its own space between the field and the results rather than
365
+ * floating over them — a soft keyboard owns the lower half of this screen, and a
366
+ * list over the field would cover the query it is completing. Arrows move the
367
+ * highlight, Enter takes it, Escape closes the list and leaves the query.
368
+ */
369
+ export const SuggestingFolders: Story = {
370
+ render: () => (
371
+ <Harness
372
+ initialValue="in:"
373
+ sections={resultSections}
374
+ preset="inbox"
375
+ suggestions={folderSuggestions}
376
+ />
377
+ ),
378
+ };
379
+
380
+ /**
381
+ * Empty query: recent searches under the brief filter chrome. Nothing is being
382
+ * searched, so the filter sheet has the row — typing hands it to the results.
383
+ */
275
384
  export const Idle: Story = {
276
385
  render: () => <Harness preset="brief" />,
277
386
  };
@@ -1,10 +1,12 @@
1
1
  import { X } from "lucide-react";
2
+ import type { ReactNode } from "react";
2
3
  import { Button } from "./button.js";
3
4
  import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
4
5
  import { SearchBar } from "./search-bar.js";
5
- import type { SearchChip } from "./search-chip-input.js";
6
+ import type { SearchChip, SearchFieldSuggest } from "./search-chip-input.js";
6
7
  import type { SearchResult } from "./search-result-row.js";
7
8
  import {
9
+ type MakeFilterActionProps,
8
10
  type SearchResultSection,
9
11
  SearchResults,
10
12
  type SearchScope,
@@ -22,8 +24,10 @@ export interface MobileSearchViewProps {
22
24
  onCancel: () => void;
23
25
  /**
24
26
  * The shared FilterSheet config (categories + Unread/Flagged/attachment, plus
25
- * the brief-only account source row). Feed it `briefFilterConfig(accounts)` or
26
- * `inboxFilterConfig()` from `filter-presets`. Omit to drop the filter chrome.
27
+ * the brief-only account source row), shown over the recent searches while the
28
+ * field is empty. Feed it `briefFilterConfig(accounts)` or `inboxFilterConfig()`
29
+ * from `filter-presets`. Omit to drop the filter chrome. A query supersedes it —
30
+ * see the component doc.
27
31
  */
28
32
  filter?: Omit<FilterSheetProps, "children">;
29
33
  /** Recent searches shown when the query is empty. */
@@ -47,7 +51,16 @@ export interface MobileSearchViewProps {
47
51
  /** What the search covers; see `SearchResultsProps`. Defaults to global. */
48
52
  scope?: SearchScope;
49
53
  /** "Make this a filter" affordance; see `SearchResultsProps`. */
50
- makeFilter?: { onClick: () => void; disabledReason?: string };
54
+ makeFilter?: MakeFilterActionProps;
55
+ /** Completions for what is being typed; see `SearchChipInput`. */
56
+ suggest?: SearchFieldSuggest;
57
+ /**
58
+ * The suggestion list itself, rendered directly under the field and above
59
+ * the results. In flow rather than over them: on a phone the soft keyboard
60
+ * takes the lower half of the screen, and a list floating over the field
61
+ * would hide the query it is completing.
62
+ */
63
+ suggestList?: ReactNode;
51
64
  }
52
65
 
53
66
  /**
@@ -57,9 +70,17 @@ export interface MobileSearchViewProps {
57
70
  * there is exactly one X). Below the bar the shared `SearchResults` body rides
58
71
  * inside the shared `FilterSheet` (the
59
72
  * same categories, Unread/Flagged/attachment toggles, and brief-only account row
60
- * the inboxes use) so search carries identical filters; pass no `filter` to drop
61
- * the chrome. Desktop reuses the same `SearchResults` body in the list pane.
62
- * Presentational and prop-driven.
73
+ * the inboxes use); pass no `filter` to drop the chrome. Desktop reuses the same
74
+ * `SearchResults` body in the list pane. Presentational and prop-driven.
75
+ *
76
+ * A query supersedes the filter row. The two narrow the same list by the same
77
+ * intent, and the row and the search's own "Make this a filter" affordance sit in
78
+ * the same place, so the filter chrome belongs to the empty field: it covers the
79
+ * recent searches, and the moment something is typed the results and their
80
+ * affordance take the space. Clearing the field brings it back.
81
+ *
82
+ * Completions for what is being typed sit between the field and the body, so
83
+ * the list takes its own space rather than covering either of them.
63
84
  *
64
85
  * Search scope passes straight through, so the phone tier holds spam out,
65
86
  * offers it and labels provenance on exactly the same terms as desktop.
@@ -80,6 +101,8 @@ export function MobileSearchView({
80
101
  onRemoveChip,
81
102
  scope,
82
103
  makeFilter,
104
+ suggest,
105
+ suggestList,
83
106
  }: MobileSearchViewProps) {
84
107
  const body = (
85
108
  <SearchResults
@@ -107,6 +130,7 @@ export function MobileSearchView({
107
130
  onRemoveChip={onRemoveChip}
108
131
  globalFocusKey={false}
109
132
  showClearButton={false}
133
+ suggest={suggest}
110
134
  />
111
135
  </div>
112
136
  <Button
@@ -119,7 +143,9 @@ export function MobileSearchView({
119
143
  />
120
144
  </header>
121
145
 
122
- {filter ? (
146
+ {suggestList}
147
+
148
+ {filter && value.trim().length === 0 ? (
123
149
  <FilterSheet {...filter}>{body}</FilterSheet>
124
150
  ) : (
125
151
  <div className="flex-1 overflow-y-auto">{body}</div>
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The reveal toggle: it starts hidden, flips only the input's `type`, and
3
+ * renames itself for the state it will produce next. Mounted against jsdom
4
+ * since the toggle is internal state driven by a real click. React is imported
5
+ * after the jsdom globals are installed so the controlled-input value tracker
6
+ * binds to jsdom's prototypes.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
10
+ import type { JSDOM } from "jsdom";
11
+ import type {
12
+ act as reactAct,
13
+ createElement as reactCreateElement,
14
+ } from "react";
15
+ import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
16
+ import type { PasswordInput as PasswordInputType } from "./password-input.js";
17
+
18
+ let dom: JSDOM;
19
+ let container: HTMLElement;
20
+ let root: Root;
21
+ let act: typeof reactAct;
22
+ let createElement: typeof reactCreateElement;
23
+ let createRoot: typeof reactCreateRoot;
24
+ let PasswordInput: typeof PasswordInputType;
25
+
26
+ before(async () => {
27
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
28
+ dom = new JSDOMCtor(
29
+ "<!doctype html><html><body><div id=root></div></body></html>",
30
+ { url: "http://localhost/", pretendToBeVisual: true },
31
+ );
32
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
33
+ globalThis.document = dom.window.document;
34
+ globalThis.HTMLElement = dom.window.HTMLElement;
35
+ globalThis.Element = dom.window.Element;
36
+ globalThis.Event = dom.window.Event;
37
+ Object.defineProperty(globalThis, "navigator", {
38
+ value: dom.window.navigator,
39
+ configurable: true,
40
+ });
41
+ (
42
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
43
+ ).IS_REACT_ACT_ENVIRONMENT = true;
44
+
45
+ const react = await import("react");
46
+ act = react.act;
47
+ createElement = react.createElement;
48
+ ({ createRoot } = await import("react-dom/client"));
49
+ ({ PasswordInput } = await import("./password-input.js"));
50
+ });
51
+
52
+ after(() => {
53
+ dom.window.close();
54
+ });
55
+
56
+ beforeEach(() => {
57
+ container = dom.window.document.getElementById(
58
+ "root",
59
+ ) as unknown as HTMLElement;
60
+ container.innerHTML = "";
61
+ root = createRoot(container);
62
+ });
63
+
64
+ afterEach(() => {
65
+ act(() => {
66
+ root.unmount();
67
+ });
68
+ });
69
+
70
+ function field(): HTMLInputElement {
71
+ return container.querySelector("input") as HTMLInputElement;
72
+ }
73
+
74
+ function toggle(): HTMLButtonElement {
75
+ return container.querySelector("button") as HTMLButtonElement;
76
+ }
77
+
78
+ function click(button: HTMLButtonElement) {
79
+ act(() => {
80
+ button.click();
81
+ });
82
+ }
83
+
84
+ describe("PasswordInput", () => {
85
+ it("hides the value and offers to show it", () => {
86
+ act(() => {
87
+ root.render(createElement(PasswordInput, { id: "pw" }));
88
+ });
89
+ assert.equal(field().getAttribute("type"), "password");
90
+ assert.equal(toggle().getAttribute("aria-label"), "Show password");
91
+ assert.equal(toggle().getAttribute("aria-pressed"), "false");
92
+ });
93
+
94
+ it("reveals the value and offers to hide it again", () => {
95
+ act(() => {
96
+ root.render(createElement(PasswordInput, { id: "pw" }));
97
+ });
98
+ click(toggle());
99
+ assert.equal(field().getAttribute("type"), "text");
100
+ assert.equal(toggle().getAttribute("aria-label"), "Hide password");
101
+ assert.equal(toggle().getAttribute("aria-pressed"), "true");
102
+
103
+ click(toggle());
104
+ assert.equal(field().getAttribute("type"), "password");
105
+ assert.equal(toggle().getAttribute("aria-label"), "Show password");
106
+ assert.equal(toggle().getAttribute("aria-pressed"), "false");
107
+ });
108
+
109
+ it("is a button that never submits its form", () => {
110
+ let submits = 0;
111
+ act(() => {
112
+ root.render(
113
+ createElement(
114
+ "form",
115
+ { onSubmit: () => submits++ },
116
+ createElement(PasswordInput, { id: "pw" }),
117
+ ),
118
+ );
119
+ });
120
+ assert.equal(toggle().getAttribute("type"), "button");
121
+ click(toggle());
122
+ assert.equal(submits, 0);
123
+ });
124
+
125
+ it("leaves the attributes password managers key off untouched", () => {
126
+ act(() => {
127
+ root.render(
128
+ createElement(PasswordInput, {
129
+ id: "pw",
130
+ name: "password",
131
+ autoComplete: "current-password",
132
+ required: true,
133
+ }),
134
+ );
135
+ });
136
+ click(toggle());
137
+ assert.equal(field().getAttribute("name"), "password");
138
+ assert.equal(field().getAttribute("autocomplete"), "current-password");
139
+ assert.equal(field().hasAttribute("required"), true);
140
+ });
141
+
142
+ it("does not take focus from the field", () => {
143
+ act(() => {
144
+ root.render(createElement(PasswordInput, { id: "pw" }));
145
+ });
146
+ assert.notEqual(dom.window.document.activeElement, toggle());
147
+ assert.equal(toggle().hasAttribute("autofocus"), false);
148
+ });
149
+ });
@@ -0,0 +1,45 @@
1
+ import { Eye, EyeOff } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { Input, type InputProps } from "./input.js";
4
+
5
+ /**
6
+ * Everything `Input` takes except the field chrome this component owns:
7
+ * `type` flips between "password" and "text", `trailing` holds the toggle, and
8
+ * the reveal button's inset assumes the boxed `default` variant.
9
+ */
10
+ export type PasswordInputProps = Omit<
11
+ InputProps,
12
+ "type" | "trailing" | "variant"
13
+ >;
14
+
15
+ /**
16
+ * A password field with an in-field reveal toggle. Only `type` changes when
17
+ * revealed — `name`, `autoComplete` and the rest reach the input untouched, so
18
+ * password managers and autofill behave exactly as they do on a bare field.
19
+ */
20
+ export function PasswordInput(props: PasswordInputProps) {
21
+ const [revealed, setRevealed] = useState(false);
22
+ const RevealIcon = revealed ? EyeOff : Eye;
23
+ const label = revealed ? "Hide password" : "Show password";
24
+
25
+ return (
26
+ <Input
27
+ {...props}
28
+ type={revealed ? "text" : "password"}
29
+ trailing={
30
+ // Negative margins grow the hit area to 44px square without
31
+ // changing the field's height.
32
+ <button
33
+ type="button"
34
+ aria-label={label}
35
+ aria-pressed={revealed}
36
+ title={label}
37
+ onClick={() => setRevealed((shown) => !shown)}
38
+ className="-my-1 -mr-2 flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-md text-fg-subtle transition-colors hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
39
+ >
40
+ <RevealIcon className="size-4" aria-hidden />
41
+ </button>
42
+ }
43
+ />
44
+ );
45
+ }
@@ -7,6 +7,7 @@ import { Card, CardBody, CardHeader, CardTitle } from "./card.js";
7
7
  import { FieldLabel } from "./field-label.js";
8
8
  import { Input } from "./input.js";
9
9
  import { ListItem } from "./list-item.js";
10
+ import { PasswordInput } from "./password-input.js";
10
11
  import { SecuritySelect } from "./security-select.js";
11
12
  import { Select } from "./select.js";
12
13
 
@@ -74,6 +75,33 @@ export const Inputs: Story = {
74
75
  ),
75
76
  };
76
77
 
78
+ export const PasswordInputs: Story = {
79
+ render: () => (
80
+ <div className="max-w-sm space-y-3 p-8">
81
+ <div>
82
+ <FieldLabel htmlFor="pw-empty">Password</FieldLabel>
83
+ <PasswordInput
84
+ id="pw-empty"
85
+ autoComplete="current-password"
86
+ placeholder="Your password"
87
+ />
88
+ </div>
89
+ <div>
90
+ <FieldLabel htmlFor="pw-filled">Password or app password</FieldLabel>
91
+ <PasswordInput
92
+ id="pw-filled"
93
+ autoComplete="current-password"
94
+ defaultValue="hunter2-app-password"
95
+ />
96
+ </div>
97
+ <p className="text-xs text-fg-subtle">
98
+ The toggle starts hidden. Its hit area is 44px square and sits beside
99
+ the value, never over it.
100
+ </p>
101
+ </div>
102
+ ),
103
+ };
104
+
77
105
  export const InlineInputs: Story = {
78
106
  render: () => (
79
107
  <div className="max-w-sm space-y-3 p-8">
@@ -2,6 +2,7 @@ import {
2
2
  type SearchChip,
3
3
  SearchChipInput,
4
4
  type SearchChipInputProps,
5
+ type SearchFieldSuggest,
5
6
  } from "./search-chip-input.js";
6
7
 
7
8
  export type { SearchChip };
@@ -46,6 +47,12 @@ export interface SearchBarProps {
46
47
  * enclosing label.
47
48
  */
48
49
  inputId?: string;
50
+ /**
51
+ * Completions for what is being typed; see `SearchChipInput`. The host
52
+ * renders the list under the field, so the field itself takes only the
53
+ * caret, the keys and the ARIA.
54
+ */
55
+ suggest?: SearchFieldSuggest;
49
56
  className?: string;
50
57
  }
51
58
 
@@ -67,6 +74,7 @@ export const SearchBar = ({
67
74
  showClearButton = true,
68
75
  size,
69
76
  inputId,
77
+ suggest,
70
78
  className,
71
79
  }: SearchBarProps) => (
72
80
  <SearchChipInput
@@ -81,6 +89,7 @@ export const SearchBar = ({
81
89
  showClearButton={showClearButton}
82
90
  size={size}
83
91
  inputId={inputId}
92
+ suggest={suggest}
84
93
  className={className}
85
94
  />
86
95
  );