@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,54 @@
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 { AppTopBar, type AppTopBarProps } from "./app-top-bar.js";
6
+
7
+ const slot = (testId: string) =>
8
+ createElement("div", { "data-testid": testId }, testId);
9
+
10
+ const render = (overrides: Partial<AppTopBarProps> = {}): string =>
11
+ renderToString(
12
+ createElement(AppTopBar, {
13
+ search: slot("search"),
14
+ ...overrides,
15
+ }),
16
+ );
17
+
18
+ describe("AppTopBar", () => {
19
+ it("renders the search slot — the bar's reason to exist", () => {
20
+ assert.match(render(), /data-testid="search"/);
21
+ });
22
+
23
+ it("renders the leading and action slots when supplied", () => {
24
+ const html = render({
25
+ leading: slot("leading"),
26
+ actions: slot("actions"),
27
+ });
28
+ assert.match(html, /data-testid="leading"/);
29
+ assert.match(html, /data-testid="actions"/);
30
+ });
31
+
32
+ it("omits the optional slots entirely rather than leaving empty boxes", () => {
33
+ const html = render();
34
+ assert.doesNotMatch(html, /data-testid="leading"/);
35
+ assert.doesNotMatch(html, /data-testid="actions"/);
36
+ });
37
+
38
+ it("lays the bar out leading · search · actions", () => {
39
+ const html = render({
40
+ leading: slot("leading"),
41
+ actions: slot("actions"),
42
+ });
43
+ assert.ok(
44
+ html.indexOf("leading") < html.indexOf("search") &&
45
+ html.indexOf("search") < html.indexOf("actions"),
46
+ "slots render in reading order",
47
+ );
48
+ });
49
+
50
+ it("is a banner landmark spanning the app, not a pane header", () => {
51
+ assert.match(render(), /<header/);
52
+ assert.match(render(), /w-full/);
53
+ });
54
+ });
@@ -0,0 +1,136 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Bug, Menu, SquarePen } from "lucide-react";
3
+ import { useState } from "react";
4
+ 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";
8
+
9
+ const meta: Meta<typeof AppTopBar> = {
10
+ title: "Mail/AppTopBar",
11
+ component: AppTopBar,
12
+ parameters: { layout: "fullscreen" },
13
+ };
14
+ export default meta;
15
+
16
+ type Story = StoryObj<typeof AppTopBar>;
17
+
18
+ const Brand = () => (
19
+ <>
20
+ <Button
21
+ variant="ghost"
22
+ icon={<Menu className="size-5" />}
23
+ aria-label="Menu"
24
+ className="px-0 lg:hidden"
25
+ />
26
+ <span className="px-1 text-sm font-semibold tracking-tight text-fg">
27
+ remit
28
+ </span>
29
+ </>
30
+ );
31
+
32
+ const Actions = () => (
33
+ <>
34
+ <Button
35
+ variant="ghost"
36
+ size="sm"
37
+ icon={<SquarePen className="size-4" />}
38
+ title="Compose"
39
+ aria-label="Compose"
40
+ />
41
+ <Button
42
+ variant="ghost"
43
+ size="sm"
44
+ icon={<Bug className="size-4" />}
45
+ title="Report a bug"
46
+ aria-label="Report a bug"
47
+ />
48
+ <Avatar name="Matthijs van Henten" email="mvh@example.com" size="sm" />
49
+ </>
50
+ );
51
+
52
+ const SCOPE: SearchChip = { id: "in:spam", label: "in:spam", tone: "scope" };
53
+
54
+ const Bar = ({ initialChips = [] }: { initialChips?: SearchChip[] }) => {
55
+ const [chips, setChips] = useState<SearchChip[]>(initialChips);
56
+ const [value, setValue] = useState("");
57
+ return (
58
+ <AppTopBar
59
+ leading={<Brand />}
60
+ actions={<Actions />}
61
+ search={
62
+ <SearchChipInput
63
+ size="lg"
64
+ chips={chips}
65
+ onRemoveChip={(id) => setChips((cs) => cs.filter((c) => c.id !== id))}
66
+ value={value}
67
+ onChange={setValue}
68
+ onClear={() => {
69
+ setValue("");
70
+ setChips([]);
71
+ }}
72
+ onClearQuery={() => setValue("")}
73
+ globalFocusKey={false}
74
+ placeholder="Search all mail"
75
+ />
76
+ }
77
+ />
78
+ );
79
+ };
80
+
81
+ /** Over the panes it spans, so the arrangement reads the way it will in the app. */
82
+ const WithPanes = ({ children }: { children: React.ReactNode }) => (
83
+ <div className="flex h-96 flex-col bg-canvas">
84
+ {children}
85
+ <div className="flex min-h-0 flex-1">
86
+ <div className="w-56 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
87
+ Nav
88
+ </div>
89
+ <div className="w-72 shrink-0 border-r border-line bg-surface p-3 text-xs text-fg-muted">
90
+ Message list
91
+ </div>
92
+ <div className="min-w-0 flex-1 p-3 text-xs text-fg-muted">
93
+ Message pane — its own toolbar lives here, under the bar
94
+ </div>
95
+ </div>
96
+ </div>
97
+ );
98
+
99
+ /** The daily brief's state: search unscoped, nothing narrowing it. */
100
+ export const Unscoped: Story = {
101
+ render: () => <Bar />,
102
+ };
103
+
104
+ /**
105
+ * A narrowing scope in the bar, tinted to mark it as the view the user is in
106
+ * rather than a filter they typed. Removing it widens the search again.
107
+ */
108
+ export const Scoped: Story = {
109
+ render: () => <Bar initialChips={[SCOPE]} />,
110
+ };
111
+
112
+ /** The arrangement: one bar over the nav, the list, and the message pane. */
113
+ export const OverTheLayout: Story = {
114
+ render: () => (
115
+ <WithPanes>
116
+ <Bar initialChips={[SCOPE]} />
117
+ </WithPanes>
118
+ ),
119
+ };
120
+
121
+ export const SearchOnly: Story = {
122
+ render: () => (
123
+ <AppTopBar
124
+ search={
125
+ <SearchChipInput
126
+ size="lg"
127
+ value=""
128
+ onChange={() => undefined}
129
+ onClear={() => undefined}
130
+ globalFocusKey={false}
131
+ placeholder="Search all mail"
132
+ />
133
+ }
134
+ />
135
+ ),
136
+ };
@@ -0,0 +1,62 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../lib/cn.js";
3
+
4
+ export interface AppTopBarProps {
5
+ /**
6
+ * Leading slot — the brand mark, and on narrow widths the nav trigger.
7
+ * Sits over the nav column, so keep it to the nav pane's width budget.
8
+ */
9
+ leading?: ReactNode;
10
+ /**
11
+ * The search field. Spans the bar's middle and is the only thing that
12
+ * grows, so the bar reads as one search surface for the whole app.
13
+ */
14
+ search: ReactNode;
15
+ /**
16
+ * Global actions — compose, feedback, avatar. Actions that belong to the
17
+ * app rather than to whatever is currently listed or open; message-context
18
+ * verbs live in the message pane's own toolbar, under this bar.
19
+ */
20
+ actions?: ReactNode;
21
+ className?: string;
22
+ }
23
+
24
+ /**
25
+ * The application top bar: one full-width row above every pane, carrying
26
+ * search and the global actions.
27
+ *
28
+ * Search sits here rather than over the message list because it is not the
29
+ * list's search — it reads across the whole app, and the bar's width is what
30
+ * says so. Leading brand and nav trigger, then the search field taking the
31
+ * room it needs, then the global actions.
32
+ *
33
+ * Presentational and slot-driven; the host supplies the wired field and
34
+ * action controls.
35
+ */
36
+ export function AppTopBar({
37
+ leading,
38
+ search,
39
+ actions,
40
+ className,
41
+ }: AppTopBarProps) {
42
+ return (
43
+ <header
44
+ className={cn(
45
+ // min-h, not a fixed height: the search field grows when its chips
46
+ // wrap onto a second line.
47
+ "flex min-h-16 w-full shrink-0 items-center gap-3 border-b border-line bg-canvas px-3 py-2",
48
+ className,
49
+ )}
50
+ >
51
+ {leading && (
52
+ <div className="flex shrink-0 items-center gap-2">{leading}</div>
53
+ )}
54
+ <div className="flex min-w-0 flex-1 justify-start">
55
+ <div className="w-full max-w-2xl">{search}</div>
56
+ </div>
57
+ {actions && (
58
+ <div className="flex shrink-0 items-center gap-1">{actions}</div>
59
+ )}
60
+ </header>
61
+ );
62
+ }
@@ -11,7 +11,7 @@ describe("MailActionToolbar", () => {
11
11
  );
12
12
  assert.match(html, /aria-label="Reply"/);
13
13
  assert.match(html, /aria-label="Move to Trash"/);
14
- assert.match(html, /aria-label="Flag"/);
14
+ assert.match(html, /aria-label="Star"/);
15
15
  assert.match(html, /aria-label="Move to mailbox"/);
16
16
  // No archive verb — Remit is IMAP-backed (move-to-folder is the equivalent).
17
17
  assert.doesNotMatch(html, /aria-label="Archive"/);
@@ -27,7 +27,7 @@ describe("MailActionToolbar", () => {
27
27
  assert.match(html, /aria-label="Forward"/);
28
28
  assert.doesNotMatch(html, /aria-label="Archive"/);
29
29
  assert.doesNotMatch(html, /aria-label="Move to Trash"/);
30
- assert.doesNotMatch(html, /aria-label="Flag"/);
30
+ assert.doesNotMatch(html, /aria-label="Star"/);
31
31
  assert.doesNotMatch(html, /aria-label="Move to mailbox"/);
32
32
  });
33
33
 
@@ -81,7 +81,7 @@ export function MailActionToolbar({
81
81
  replyAllTitle = "Reply all (a)",
82
82
  forwardTitle = "Forward (f)",
83
83
  deleteTitle = "Move to Trash (#)",
84
- flagTitle = "Flag (s)",
84
+ flagTitle = "Star (s)",
85
85
  children,
86
86
  className,
87
87
  }: MailActionToolbarProps) {
@@ -158,7 +158,7 @@ export function MailActionToolbar({
158
158
  />
159
159
  }
160
160
  title={flagTitle}
161
- aria-label="Flag"
161
+ aria-label="Star"
162
162
  onClick={act("flag", onToggleStar)}
163
163
  />
164
164
  </>
@@ -20,7 +20,7 @@ describe("MobileMessageActionBar", () => {
20
20
  }),
21
21
  }),
22
22
  );
23
- assert.equal(countMatches(html, 'aria-label="Flag"'), 1);
23
+ assert.equal(countMatches(html, 'aria-label="Star"'), 1);
24
24
  assert.equal(countMatches(html, 'aria-label="Move to folder"'), 1);
25
25
  assert.equal(countMatches(html, 'aria-label="Move to Trash"'), 1);
26
26
  assert.doesNotMatch(html, /aria-label="Archive"/);
@@ -30,7 +30,7 @@ describe("MobileMessageActionBar", () => {
30
30
  const html = renderToString(
31
31
  createElement(MobileMessageActionBar, { ...base, isStarred: true }),
32
32
  );
33
- assert.match(html, /aria-label="Remove flag"/);
33
+ assert.match(html, /aria-label="Unstar"/);
34
34
  assert.match(html, /aria-pressed="true"/);
35
35
  });
36
36
 
@@ -141,9 +141,9 @@ export function MobileMessageActionBar({
141
141
  />
142
142
  }
143
143
  onClick={act("star", onToggleStar)}
144
- aria-label={isStarred ? "Remove flag" : "Flag"}
144
+ aria-label={isStarred ? "Unstar" : "Star"}
145
145
  aria-pressed={isStarred}
146
- title={isStarred ? "Remove flag" : "Flag"}
146
+ title={isStarred ? "Unstar" : "Star"}
147
147
  className={TOUCH}
148
148
  />
149
149
  {moveSlot}
@@ -87,7 +87,7 @@ describe("MobileReadingPane", () => {
87
87
  // One expanded message ⇒ one Reply / Flag / Trash cluster; the two
88
88
  // collapsed rows carry no bar.
89
89
  assert.equal(countMatches(html, 'aria-label="Reply"'), 1);
90
- assert.equal(countMatches(html, 'aria-label="Flag"'), 1);
90
+ assert.equal(countMatches(html, 'aria-label="Star"'), 1);
91
91
  assert.equal(countMatches(html, 'aria-label="Move to Trash"'), 1);
92
92
  });
93
93
 
@@ -6,6 +6,7 @@ import {
6
6
  inboxFilterConfig,
7
7
  } from "../filter-presets.js";
8
8
  import { MobileSearchView } from "./mobile-search-view.js";
9
+ import type { SearchChip } from "./search-chip-input.js";
9
10
  import type { SearchResult } from "./search-result-row.js";
10
11
  import type { SearchResultSection } from "./search-results.js";
11
12
 
@@ -100,16 +101,19 @@ type Preset = "brief" | "inbox";
100
101
 
101
102
  function Harness({
102
103
  initialValue = "",
104
+ initialChips = [],
103
105
  loading,
104
106
  sections,
105
107
  preset,
106
108
  }: {
107
109
  initialValue?: string;
110
+ initialChips?: SearchChip[];
108
111
  loading?: boolean;
109
112
  sections?: SearchResultSection[];
110
113
  preset: Preset;
111
114
  }) {
112
115
  const [value, setValue] = useState(initialValue);
116
+ const [chips, setChips] = useState<SearchChip[]>(initialChips);
113
117
  const [selectedCategory, setSelectedCategory] = useState("all");
114
118
  const [activeFilters, setActiveFilters] = useState<Set<string>>(new Set());
115
119
  const [activeSource, setActiveSource] = useState("personal");
@@ -152,6 +156,8 @@ function Harness({
152
156
  onChange={setValue}
153
157
  onClear={() => setValue("")}
154
158
  onCancel={() => undefined}
159
+ chips={chips}
160
+ onRemoveChip={(id) => setChips((cs) => cs.filter((c) => c.id !== id))}
155
161
  filter={{
156
162
  ...base,
157
163
  selectedCategory,
@@ -245,3 +251,20 @@ export const RelatedSelectable: Story = {
245
251
  />
246
252
  ),
247
253
  };
254
+
255
+ /**
256
+ * The narrowing expression on mobile: the same `SearchChipInput` the desktop
257
+ * top bar uses, inside the full-screen takeover's own chrome. The chip is
258
+ * removable in place — backspace at the start of the text reaches it just as it
259
+ * does on desktop.
260
+ */
261
+ export const ScopedByChip: Story = {
262
+ render: () => (
263
+ <Harness
264
+ initialValue="invoice"
265
+ initialChips={[{ id: "in:spam", label: "in:spam" }]}
266
+ sections={resultSections}
267
+ preset="inbox"
268
+ />
269
+ ),
270
+ };
@@ -2,6 +2,7 @@ import { X } from "lucide-react";
2
2
  import { Button } from "./button.js";
3
3
  import { FilterSheet, type FilterSheetProps } from "./filter-sheet.js";
4
4
  import { SearchBar } from "./search-bar.js";
5
+ import type { SearchChip } from "./search-chip-input.js";
5
6
  import type { SearchResult } from "./search-result-row.js";
6
7
  import { type SearchResultSection, SearchResults } from "./search-results.js";
7
8
 
@@ -30,6 +31,15 @@ export interface MobileSearchViewProps {
30
31
  onSelectResult?: (result: SearchResult) => void;
31
32
  /** Active filter-token chips parsed from the query; see `SearchResultsProps`. */
32
33
  tokens?: { label: string; onRemove: () => void }[];
34
+ /**
35
+ * Narrowing terms rendered inline inside the search field, as part of the
36
+ * editable expression — the same `SearchChipInput` the desktop top bar uses.
37
+ * Supersedes `tokens` for chips the user can act on: `tokens` renders them as
38
+ * a static row above the results, `chips` puts them in the field where
39
+ * backspace and the caret keys reach them.
40
+ */
41
+ chips?: readonly SearchChip[];
42
+ onRemoveChip?: (id: string) => void;
33
43
  }
34
44
 
35
45
  /**
@@ -55,6 +65,8 @@ export function MobileSearchView({
55
65
  loading,
56
66
  onSelectResult,
57
67
  tokens,
68
+ chips,
69
+ onRemoveChip,
58
70
  }: MobileSearchViewProps) {
59
71
  const body = (
60
72
  <SearchResults
@@ -76,6 +88,8 @@ export function MobileSearchView({
76
88
  value={value}
77
89
  onChange={onChange}
78
90
  onClear={onClear}
91
+ chips={chips}
92
+ onRemoveChip={onRemoveChip}
79
93
  globalFocusKey={false}
80
94
  showClearButton={false}
81
95
  />
@@ -69,6 +69,18 @@ describe("NavSidebar", () => {
69
69
  assert.match(html, /Daily brief/);
70
70
  });
71
71
 
72
+ it("labels the \\Flagged virtual mailbox 'Starred'", () => {
73
+ const html = renderToString(
74
+ createElement(NavSidebar, {
75
+ accounts,
76
+ selectedNavId: "flagged",
77
+ onSelectNav: () => undefined,
78
+ }),
79
+ );
80
+ assert.match(html, /Starred/);
81
+ assert.doesNotMatch(html, /Flagged/);
82
+ });
83
+
72
84
  it("renders system and custom mailbox names", () => {
73
85
  const html = renderToString(
74
86
  createElement(NavSidebar, {
@@ -580,8 +580,8 @@ export function NavSidebar({
580
580
  navId="flagged"
581
581
  linkComponent={linkComponent}
582
582
  icon={<Star className="size-4" />}
583
- label="Flagged"
584
- ariaLabel="Flagged"
583
+ label="Starred"
584
+ ariaLabel="Starred"
585
585
  active={selectedNavId === "flagged"}
586
586
  onClick={() => onSelectNav?.("flagged")}
587
587
  />
@@ -53,3 +53,20 @@ describe("SearchBar", () => {
53
53
  assert.match(html, /value="receipt"/);
54
54
  });
55
55
  });
56
+
57
+ describe("SearchBar narrowing chips", () => {
58
+ it("carries the chips inside the field, alongside the typed text", () => {
59
+ const html = renderToString(
60
+ createElement(SearchBar, {
61
+ value: "invoice",
62
+ onChange: noop,
63
+ onClear: noop,
64
+ chips: [{ id: "in:spam", label: "in:spam" }],
65
+ onRemoveChip: noop,
66
+ }),
67
+ );
68
+ assert.match(html, /in:spam/);
69
+ assert.match(html, /aria-label="Remove filter: in:spam"/);
70
+ assert.match(html, /value="invoice"/);
71
+ });
72
+ });
@@ -41,6 +41,8 @@ export const WithQuery: Story = {
41
41
  export const Focused: Story = {
42
42
  render: () => <Interactive />,
43
43
  play: async ({ canvasElement }) => {
44
- canvasElement.querySelector<HTMLInputElement>("#mail-search")?.focus();
44
+ canvasElement
45
+ .querySelector<HTMLInputElement>('input[aria-label="Search mail"]')
46
+ ?.focus();
45
47
  },
46
48
  };
@@ -1,6 +1,10 @@
1
- import { Search, X } from "lucide-react";
2
- import { useCallback, useEffect, useRef } from "react";
3
- import { cn } from "../lib/cn.js";
1
+ import {
2
+ type SearchChip,
3
+ SearchChipInput,
4
+ type SearchChipInputProps,
5
+ } from "./search-chip-input.js";
6
+
7
+ export type { SearchChip };
4
8
 
5
9
  export interface SearchBarProps {
6
10
  value: string;
@@ -16,6 +20,12 @@ export interface SearchBarProps {
16
20
  * `onClear` when omitted.
17
21
  */
18
22
  onClearQuery?: () => void;
23
+ /**
24
+ * Narrowing terms shown inline ahead of the typed text. Omit for a plain
25
+ * text field; see `SearchChipInput` for the expression semantics.
26
+ */
27
+ chips?: readonly SearchChip[];
28
+ onRemoveChip?: (id: string) => void;
19
29
  placeholder?: string;
20
30
  /**
21
31
  * Bind the global "/" shortcut that focuses the field from anywhere on the
@@ -28,91 +38,49 @@ export interface SearchBarProps {
28
38
  * clear-and-close so there is exactly one X (Esc still clears the query).
29
39
  */
30
40
  showClearButton?: boolean;
41
+ /** Field scale — `lg` for the global top bar. See `SearchChipInput`. */
42
+ size?: SearchChipInputProps["size"];
43
+ /**
44
+ * DOM id of the text input. Defaults to a generated, per-instance id; see
45
+ * `SearchChipInput`, which explains why a shared default breaks the field's
46
+ * enclosing label.
47
+ */
48
+ inputId?: string;
49
+ className?: string;
31
50
  }
32
51
 
33
- const isEditableTarget = (target: EventTarget | null): boolean => {
34
- if (!(target instanceof HTMLElement)) return false;
35
- return (
36
- target.tagName === "INPUT" ||
37
- target.tagName === "TEXTAREA" ||
38
- target.isContentEditable
39
- );
40
- };
41
-
52
+ /**
53
+ * The mail search field. A thin naming layer over `SearchChipInput`, which owns
54
+ * the chips-plus-text expression and its keyboard contract; every search
55
+ * surface (list header, mobile takeover, global top bar) renders this one
56
+ * field so the behaviour cannot drift between them.
57
+ */
42
58
  export const SearchBar = ({
43
59
  value,
44
60
  onChange,
45
61
  onClear,
46
62
  onClearQuery,
63
+ chips,
64
+ onRemoveChip,
47
65
  placeholder = "Search mail...",
48
66
  globalFocusKey = true,
49
67
  showClearButton = true,
50
- }: SearchBarProps) => {
51
- const inputRef = useRef<HTMLInputElement>(null);
52
- const clearQuery = onClearQuery ?? onClear;
53
-
54
- const handleClear = useCallback(() => {
55
- onClear();
56
- inputRef.current?.focus();
57
- }, [onClear]);
58
-
59
- const handleKeyDown = useCallback(
60
- (event: React.KeyboardEvent<HTMLInputElement>) => {
61
- if (event.key !== "Escape") return;
62
- if (value) {
63
- clearQuery();
64
- return;
65
- }
66
- inputRef.current?.blur();
67
- },
68
- [value, clearQuery],
69
- );
70
-
71
- useEffect(() => {
72
- if (!globalFocusKey) return;
73
- const handleGlobalSlash = (event: KeyboardEvent) => {
74
- if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) {
75
- return;
76
- }
77
- if (isEditableTarget(event.target)) return;
78
- event.preventDefault();
79
- inputRef.current?.focus();
80
- };
81
- window.addEventListener("keydown", handleGlobalSlash);
82
- return () => window.removeEventListener("keydown", handleGlobalSlash);
83
- }, [globalFocusKey]);
84
-
85
- return (
86
- <div className="relative w-full">
87
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-fg-muted pointer-events-none" />
88
- <input
89
- ref={inputRef}
90
- id="mail-search"
91
- name="q"
92
- type="text"
93
- aria-label="Search mail"
94
- value={value}
95
- onChange={(e) => onChange(e.target.value)}
96
- onKeyDown={handleKeyDown}
97
- placeholder={placeholder}
98
- className={cn(
99
- "w-full pl-9 pr-9 py-1.5 text-sm rounded-md",
100
- "bg-surface-sunken/50 border border-transparent",
101
- "focus:bg-canvas focus:border-line focus:outline-none focus:ring-2 focus:ring-ring",
102
- "placeholder:text-fg-muted",
103
- "transition-colors",
104
- )}
105
- />
106
- {value && showClearButton && (
107
- <button
108
- type="button"
109
- onClick={handleClear}
110
- className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-surface-raised transition-colors"
111
- aria-label="Clear search"
112
- >
113
- <X className="size-4 text-fg-muted" />
114
- </button>
115
- )}
116
- </div>
117
- );
118
- };
68
+ size,
69
+ inputId,
70
+ className,
71
+ }: SearchBarProps) => (
72
+ <SearchChipInput
73
+ value={value}
74
+ onChange={onChange}
75
+ onClear={onClear}
76
+ onClearQuery={onClearQuery}
77
+ chips={chips}
78
+ onRemoveChip={onRemoveChip}
79
+ placeholder={placeholder}
80
+ globalFocusKey={globalFocusKey}
81
+ showClearButton={showClearButton}
82
+ size={size}
83
+ inputId={inputId}
84
+ className={className}
85
+ />
86
+ );