@zentauri-ui/zentauri-components 1.4.3 → 1.4.5

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/README.md CHANGED
@@ -58,6 +58,7 @@ Import static primitives from `@zentauri-ui/zentauri-components/ui/<subpath>` wh
58
58
  | Modal | `modal` | `modal/animated` |
59
59
  | Pagination | `pagination` | — |
60
60
  | Progress | `progress` | `progress/animated` |
61
+ | Search | `search` | - |
61
62
  | Select | `select` | — |
62
63
  | Skeleton | `skeleton` | `skeleton/animated` |
63
64
  | Slider | `slider` | — |
@@ -283,16 +284,16 @@ Which UI folders are valid for `add` is driven by **`cli/registry.json`**: a gen
283
284
  Call the published binary by name after the package (recommended so `npx` does not treat the first word as a shell command):
284
285
 
285
286
  ```bash
286
- npx @zentauri-ui/zentauri-components zentauri-components init
287
- npx @zentauri-ui/zentauri-components zentauri-components add buttons inputs
288
- npx @zentauri-ui/zentauri-components zentauri-components -h
287
+ npx @zentauri-ui/zentauri-components init
288
+ npx @zentauri-ui/zentauri-components add buttons inputs
289
+ npx @zentauri-ui/zentauri-components -h
289
290
  ```
290
291
 
291
292
  **Hooks only** (copy `src/hooks/<name>` into your app, plus sibling hook dependencies such as `useMediaQuery` when needed):
292
293
 
293
294
  ```bash
294
- npx @zentauri-ui/zentauri-components zentauri-components add hook useWindowSize
295
- npx @zentauri-ui/zentauri-components zentauri-components add hook useToggle useDebouncedValue
295
+ npx @zentauri-ui/zentauri-components add hook useWindowSize
296
+ npx @zentauri-ui/zentauri-components add hook useToggle useDebouncedValue
296
297
  ```
297
298
 
298
299
  The **`zentauri-ui`** binary is the same entry as **`zentauri-components`**. If `npx` still mis-resolves, pin the package:
package/cli/index.mjs CHANGED
@@ -165,8 +165,8 @@ Use hooks from the package without copying (after npm install):
165
165
  import { useWindowSize } from "@zentauri-ui/zentauri-components/hooks/useWindowSize";
166
166
 
167
167
  Published package:
168
- npx @zentauri-ui/zentauri-components zentauri-components init
169
- npx @zentauri-ui/zentauri-components zentauri-components add accordion buttons inputs
168
+ npx @zentauri-ui/zentauri-components init
169
+ npx @zentauri-ui/zentauri-components add accordion buttons inputs
170
170
 
171
171
  If npx does not pick the right binary:
172
172
  npx --yes --package=@zentauri-ui/zentauri-components zentauri-components init
package/cli/registry.json CHANGED
@@ -18,6 +18,7 @@
18
18
  "modal",
19
19
  "pagination",
20
20
  "progress",
21
+ "search",
21
22
  "select",
22
23
  "skeleton",
23
24
  "slider",
@@ -0,0 +1,15 @@
1
+ import type { SearchFilterable } from "./types";
2
+ export type FilterSearchSuggestionsOptions = {
3
+ /** Maximum number of matches returned. */
4
+ maxResults?: number;
5
+ };
6
+ /**
7
+ * Returns items whose label, description, href, or keywords contain the query (case-insensitive).
8
+ * Whitespace-only query matches no items.
9
+ */
10
+ export declare function filterSearchSuggestions<T extends SearchFilterable>({ query, items, options, }: {
11
+ query: string;
12
+ items: readonly T[];
13
+ options?: FilterSearchSuggestionsOptions;
14
+ }): T[];
15
+ //# sourceMappingURL=filter-search-suggestions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filter-search-suggestions.d.ts","sourceRoot":"","sources":["../../../src/ui/search/filter-search-suggestions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEhD,MAAM,MAAM,8BAA8B,GAAG;IAC3C,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,gBAAgB,EAAE,EAClE,KAAK,EACL,KAAK,EACL,OAA4B,GAC7B,EAAE;IACD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,8BAA8B,CAAC;CAC1C,GAAG,CAAC,EAAE,CAuBN"}
@@ -0,0 +1,7 @@
1
+ export { SearchBar } from "./search-bar";
2
+ export { SearchSuggestionList } from "./search-suggestion-list";
3
+ export { searchSuggestionOptionDomId } from "./search-suggestion-utils";
4
+ export { filterSearchSuggestions } from "./filter-search-suggestions";
5
+ export type { SearchBarProps, SearchSuggestionItem, SearchSuggestionListProps, SearchFilterable, } from "./types";
6
+ export type { FilterSearchSuggestionsOptions } from "./filter-search-suggestions";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/ui/search/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAChE,OAAO,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AACxE,OAAO,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AACtE,YAAY,EACV,cAAc,EACd,oBAAoB,EACpB,yBAAyB,EACzB,gBAAgB,GACjB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,8BAA8B,EAAE,MAAM,6BAA6B,CAAC"}
@@ -0,0 +1,6 @@
1
+ import type { SearchBarProps } from "./types";
2
+ export declare const SearchBar: {
3
+ ({ value, onValueChange, leadingSlot, className, inputClassName, appearance, inputSize, ring, id, onChange, disabled, type, comboboxListboxId, comboboxActiveOptionId, comboboxExpanded, ref, ...rest }: SearchBarProps): import("react/jsx-runtime").JSX.Element;
4
+ displayName: string;
5
+ };
6
+ //# sourceMappingURL=search-bar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-bar.d.ts","sourceRoot":"","sources":["../../../src/ui/search/search-bar.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,eAAO,MAAM,SAAS;6MAmBjB,cAAc;;CAoDlB,CAAA"}
@@ -0,0 +1,6 @@
1
+ import type { SearchSuggestionListProps } from "./types";
2
+ export declare function SearchSuggestionList({ items, onSelect, activeId, onActiveIdChange, listboxId, className, listClassName, emptyLabel, }: SearchSuggestionListProps): import("react/jsx-runtime").JSX.Element;
3
+ export declare namespace SearchSuggestionList {
4
+ var displayName: string;
5
+ }
6
+ //# sourceMappingURL=search-suggestion-list.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-suggestion-list.d.ts","sourceRoot":"","sources":["../../../src/ui/search/search-suggestion-list.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,SAAS,CAAC;AAKzD,wBAAgB,oBAAoB,CAAC,EACnC,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,gBAAgB,EAChB,SAAS,EACT,SAAS,EACT,aAAa,EACb,UAAU,GACX,EAAE,yBAAyB,2CAsE3B;yBA/Ee,oBAAoB"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Builds a stable DOM id for a listbox option so `aria-activedescendant` on the combobox
3
+ * input can reference it. Safe for href-like `itemId` strings.
4
+ */
5
+ export declare function searchSuggestionOptionDomId(listboxId: string, itemId: string): string;
6
+ //# sourceMappingURL=search-suggestion-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-suggestion-utils.d.ts","sourceRoot":"","sources":["../../../src/ui/search/search-suggestion-utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAGrF"}
@@ -0,0 +1,44 @@
1
+ import type { InputHTMLAttributes, ReactNode, Ref } from "react";
2
+ import type { VariantProps } from "class-variance-authority";
3
+ import type { inputVariants } from "../inputs/variants";
4
+ export type SearchBarProps = Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "children" | "role"> & {
5
+ value: string;
6
+ onValueChange?: (value: string) => void;
7
+ leadingSlot?: ReactNode;
8
+ inputClassName?: string;
9
+ appearance?: VariantProps<typeof inputVariants>["appearance"];
10
+ inputSize?: VariantProps<typeof inputVariants>["size"];
11
+ ring?: VariantProps<typeof inputVariants>["ring"];
12
+ /** When set, the input exposes combobox semantics wired to a `role="listbox"` with this id. */
13
+ comboboxListboxId?: string;
14
+ /** Element id of the active option (from `searchSuggestionOptionDomId`) for `aria-activedescendant`. */
15
+ comboboxActiveOptionId?: string;
16
+ /** Whether the suggestion list is visibly expanded (controls `aria-expanded`). */
17
+ comboboxExpanded?: boolean;
18
+ ref?: Ref<HTMLInputElement>;
19
+ };
20
+ export type SearchSuggestionItem = {
21
+ id: string;
22
+ label: string;
23
+ description?: string;
24
+ group?: string;
25
+ };
26
+ export type SearchSuggestionListProps = {
27
+ items: readonly SearchSuggestionItem[];
28
+ onSelect: (id: string) => void;
29
+ activeId?: string;
30
+ onActiveIdChange?: (id: string | undefined) => void;
31
+ /** Pass the same id as `comboboxListboxId` on `SearchBar` for ARIA wiring. */
32
+ listboxId?: string;
33
+ className?: string;
34
+ listClassName?: string;
35
+ emptyLabel?: ReactNode;
36
+ };
37
+ export type SearchFilterable = {
38
+ id: string;
39
+ label: string;
40
+ description?: string;
41
+ keywords?: readonly string[];
42
+ href?: string;
43
+ };
44
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/ui/search/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC;AAEjE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAE7D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD,MAAM,MAAM,cAAc,GAAG,IAAI,CAC/B,mBAAmB,CAAC,gBAAgB,CAAC,EACrC,MAAM,GAAG,UAAU,GAAG,MAAM,CAC7B,GAAG;IACF,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,YAAY,CAAC,OAAO,aAAa,CAAC,CAAC,YAAY,CAAC,CAAC;IAC9D,SAAS,CAAC,EAAE,YAAY,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;IACvD,IAAI,CAAC,EAAE,YAAY,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;IAClD,+FAA+F;IAC/F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wGAAwG;IACxG,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,kFAAkF;IAClF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,GAAG,CAAC,EAAE,GAAG,CAAC,gBAAgB,CAAC,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,KAAK,EAAE,SAAS,oBAAoB,EAAE,CAAC;IACvC,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IACpD,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,SAAS,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,CAAC"}
@@ -0,0 +1,195 @@
1
+ "use client";
2
+ 'use strict';
3
+
4
+ var chunkQZKMFSH5_js = require('../chunk-QZKMFSH5.js');
5
+ var chunkUOZYPWDZ_js = require('../chunk-UOZYPWDZ.js');
6
+ var react = require('react');
7
+ var jsxRuntime = require('react/jsx-runtime');
8
+
9
+ var SearchBar = function SearchBar2({
10
+ value,
11
+ onValueChange,
12
+ leadingSlot,
13
+ className,
14
+ inputClassName,
15
+ appearance = "default",
16
+ inputSize = "md",
17
+ ring = true,
18
+ id,
19
+ onChange,
20
+ disabled,
21
+ type,
22
+ comboboxListboxId,
23
+ comboboxActiveOptionId,
24
+ comboboxExpanded,
25
+ ref,
26
+ ...rest
27
+ }) {
28
+ const generatedId = react.useId();
29
+ const controlId = id ?? generatedId;
30
+ const combobox = Boolean(comboboxListboxId);
31
+ return /* @__PURE__ */ jsxRuntime.jsxs(
32
+ "div",
33
+ {
34
+ "data-slot": "search-bar",
35
+ className: chunkUOZYPWDZ_js.cn("relative flex w-full min-w-0 items-center", className),
36
+ children: [
37
+ leadingSlot ? /* @__PURE__ */ jsxRuntime.jsx(
38
+ "span",
39
+ {
40
+ className: "pointer-events-none absolute left-3 top-1/2 z-1 flex -translate-y-1/2 text-slate-400 [&_svg]:size-4",
41
+ "aria-hidden": true,
42
+ children: leadingSlot
43
+ }
44
+ ) : null,
45
+ /* @__PURE__ */ jsxRuntime.jsx(
46
+ "input",
47
+ {
48
+ ref,
49
+ id: controlId,
50
+ type: type ?? "search",
51
+ autoComplete: "off",
52
+ spellCheck: false,
53
+ disabled,
54
+ value,
55
+ "data-slot": "search-bar-input",
56
+ className: chunkUOZYPWDZ_js.cn(
57
+ chunkQZKMFSH5_js.inputVariants({ appearance, size: inputSize, ring, as: "input" }),
58
+ leadingSlot ? "pl-10" : null,
59
+ inputClassName
60
+ ),
61
+ onChange: (event) => {
62
+ onChange?.(event);
63
+ onValueChange?.(event.target.value);
64
+ },
65
+ ...combobox ? {
66
+ role: "combobox",
67
+ "aria-autocomplete": "list",
68
+ "aria-controls": comboboxListboxId,
69
+ "aria-expanded": comboboxExpanded ?? false,
70
+ ...comboboxActiveOptionId ? { "aria-activedescendant": comboboxActiveOptionId } : {}
71
+ } : {},
72
+ ...rest
73
+ }
74
+ )
75
+ ]
76
+ }
77
+ );
78
+ };
79
+ SearchBar.displayName = "SearchBar";
80
+
81
+ // src/ui/search/search-suggestion-utils.ts
82
+ function searchSuggestionOptionDomId(listboxId, itemId) {
83
+ const safe = itemId.replace(/[^a-zA-Z0-9_-]/g, "_");
84
+ return `${listboxId}_opt_${safe}`;
85
+ }
86
+ var rowClassName = "flex w-full flex-col gap-0.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-white/5 focus-visible:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50";
87
+ function SearchSuggestionList({
88
+ items,
89
+ onSelect,
90
+ activeId,
91
+ onActiveIdChange,
92
+ listboxId,
93
+ className,
94
+ listClassName,
95
+ emptyLabel
96
+ }) {
97
+ if (items.length === 0) {
98
+ return /* @__PURE__ */ jsxRuntime.jsx(
99
+ "div",
100
+ {
101
+ "data-slot": "search-suggestion-list-empty",
102
+ className: chunkUOZYPWDZ_js.cn("px-1 py-6 text-center text-sm text-slate-500", className),
103
+ children: emptyLabel ?? "No matches."
104
+ }
105
+ );
106
+ }
107
+ let lastGroup;
108
+ const useListbox = Boolean(listboxId);
109
+ return /* @__PURE__ */ jsxRuntime.jsx(
110
+ "nav",
111
+ {
112
+ "data-slot": "search-suggestion-list",
113
+ "aria-label": "Search results",
114
+ className: chunkUOZYPWDZ_js.cn("flex max-h-[min(50vh,360px)] flex-col gap-1 overflow-y-auto pr-1", className),
115
+ children: /* @__PURE__ */ jsxRuntime.jsx(
116
+ "div",
117
+ {
118
+ ...useListbox ? {
119
+ id: listboxId,
120
+ role: "listbox"
121
+ } : {},
122
+ className: chunkUOZYPWDZ_js.cn("flex flex-col gap-0.5", listClassName),
123
+ children: items.map((item) => {
124
+ const showGroup = item.group && item.group !== lastGroup;
125
+ if (item.group) {
126
+ lastGroup = item.group;
127
+ }
128
+ const isActive = activeId === item.id;
129
+ const optionDomId = useListbox && listboxId ? searchSuggestionOptionDomId(listboxId, item.id) : void 0;
130
+ return /* @__PURE__ */ jsxRuntime.jsxs(react.Fragment, { children: [
131
+ showGroup ? /* @__PURE__ */ jsxRuntime.jsx(
132
+ "div",
133
+ {
134
+ role: "presentation",
135
+ className: "sticky top-0 z-1 bg-slate-950/95 px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm",
136
+ children: item.group
137
+ }
138
+ ) : null,
139
+ /* @__PURE__ */ jsxRuntime.jsxs(
140
+ "button",
141
+ {
142
+ type: "button",
143
+ id: optionDomId,
144
+ role: useListbox ? "option" : void 0,
145
+ "aria-selected": useListbox ? isActive : void 0,
146
+ "data-active": isActive ? "" : void 0,
147
+ className: chunkUOZYPWDZ_js.cn(rowClassName, isActive ? "bg-white/5" : null),
148
+ onMouseEnter: () => onActiveIdChange?.(item.id),
149
+ onFocus: () => onActiveIdChange?.(item.id),
150
+ onClick: () => onSelect(item.id),
151
+ children: [
152
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-medium text-slate-100", children: item.label }),
153
+ item.description ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "truncate text-xs text-slate-500", children: item.description }) : null
154
+ ]
155
+ }
156
+ )
157
+ ] }, item.id);
158
+ })
159
+ }
160
+ )
161
+ }
162
+ );
163
+ }
164
+ SearchSuggestionList.displayName = "SearchSuggestionList";
165
+
166
+ // src/ui/search/filter-search-suggestions.ts
167
+ function filterSearchSuggestions({
168
+ query,
169
+ items,
170
+ options = { maxResults: 20 }
171
+ }) {
172
+ const maxResults = options.maxResults ?? 20;
173
+ const normalized = query.trim().toLowerCase();
174
+ if (!normalized) {
175
+ return [];
176
+ }
177
+ const matches = [];
178
+ for (const item of items) {
179
+ const isMatch = item.label.toLowerCase().includes(normalized) || item.description?.toLowerCase().includes(normalized) || item.href?.toLowerCase().includes(normalized) || item.keywords?.some((k) => k.toLowerCase().includes(normalized));
180
+ if (isMatch) {
181
+ matches.push(item);
182
+ if (matches.length >= maxResults || maxResults <= 0) {
183
+ break;
184
+ }
185
+ }
186
+ }
187
+ return matches;
188
+ }
189
+
190
+ exports.SearchBar = SearchBar;
191
+ exports.SearchSuggestionList = SearchSuggestionList;
192
+ exports.filterSearchSuggestions = filterSearchSuggestions;
193
+ exports.searchSuggestionOptionDomId = searchSuggestionOptionDomId;
194
+ //# sourceMappingURL=search.js.map
195
+ //# sourceMappingURL=search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/ui/search/search-bar.tsx","../../src/ui/search/search-suggestion-utils.ts","../../src/ui/search/search-suggestion-list.tsx","../../src/ui/search/filter-search-suggestions.ts"],"names":["SearchBar","useId","jsxs","cn","jsx","inputVariants","Fragment"],"mappings":"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","file":"search.js","sourcesContent":["\"use client\";\n\nimport { forwardRef, useId } from \"react\";\n\nimport { cn } from \"../../lib/utils\";\nimport { inputVariants } from \"../inputs/variants\";\n\nimport type { SearchBarProps } from \"./types\";\n\nexport const SearchBar = function SearchBar(\n {\n value,\n onValueChange,\n leadingSlot,\n className,\n inputClassName,\n appearance = \"default\",\n inputSize = \"md\",\n ring = true,\n id,\n onChange,\n disabled,\n type,\n comboboxListboxId,\n comboboxActiveOptionId,\n comboboxExpanded,\n ref,\n ...rest\n }: SearchBarProps,\n) {\n const generatedId = useId();\n const controlId = id ?? generatedId;\n const combobox = Boolean(comboboxListboxId);\n\n return (\n <div\n data-slot=\"search-bar\"\n className={cn(\"relative flex w-full min-w-0 items-center\", className)}\n >\n {leadingSlot ? (\n <span\n className=\"pointer-events-none absolute left-3 top-1/2 z-1 flex -translate-y-1/2 text-slate-400 [&_svg]:size-4\"\n aria-hidden\n >\n {leadingSlot}\n </span>\n ) : null}\n <input\n ref={ref}\n id={controlId}\n type={type ?? \"search\"}\n autoComplete=\"off\"\n spellCheck={false}\n disabled={disabled}\n value={value}\n data-slot=\"search-bar-input\"\n className={cn(\n inputVariants({ appearance, size: inputSize, ring, as: \"input\" }),\n leadingSlot ? \"pl-10\" : null,\n inputClassName,\n )}\n onChange={(event) => {\n onChange?.(event);\n onValueChange?.(event.target.value);\n }}\n {...(combobox\n ? {\n role: \"combobox\" as const,\n \"aria-autocomplete\": \"list\" as const,\n \"aria-controls\": comboboxListboxId,\n \"aria-expanded\": comboboxExpanded ?? false,\n ...(comboboxActiveOptionId\n ? { \"aria-activedescendant\": comboboxActiveOptionId }\n : {}),\n }\n : {})}\n {...rest}\n />\n </div>\n );\n}\n\nSearchBar.displayName = \"SearchBar\";\n","/**\n * Builds a stable DOM id for a listbox option so `aria-activedescendant` on the combobox\n * input can reference it. Safe for href-like `itemId` strings.\n */\nexport function searchSuggestionOptionDomId(listboxId: string, itemId: string): string {\n const safe = itemId.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n return `${listboxId}_opt_${safe}`;\n}\n","\"use client\";\n\nimport { Fragment } from \"react\";\n\nimport { cn } from \"../../lib/utils\";\nimport { searchSuggestionOptionDomId } from \"./search-suggestion-utils\";\n\nimport type { SearchSuggestionListProps } from \"./types\";\n\nconst rowClassName =\n \"flex w-full flex-col gap-0.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-white/5 focus-visible:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50\";\n\nexport function SearchSuggestionList({\n items,\n onSelect,\n activeId,\n onActiveIdChange,\n listboxId,\n className,\n listClassName,\n emptyLabel,\n}: SearchSuggestionListProps) {\n if (items.length === 0) {\n return (\n <div\n data-slot=\"search-suggestion-list-empty\"\n className={cn(\"px-1 py-6 text-center text-sm text-slate-500\", className)}\n >\n {emptyLabel ?? \"No matches.\"}\n </div>\n );\n }\n\n let lastGroup: string | undefined;\n const useListbox = Boolean(listboxId);\n\n return (\n <nav\n data-slot=\"search-suggestion-list\"\n aria-label=\"Search results\"\n className={cn(\"flex max-h-[min(50vh,360px)] flex-col gap-1 overflow-y-auto pr-1\", className)}\n >\n <div\n {...(useListbox\n ? {\n id: listboxId,\n role: \"listbox\" as const,\n }\n : {})}\n className={cn(\"flex flex-col gap-0.5\", listClassName)}\n >\n {items.map((item) => {\n const showGroup = item.group && item.group !== lastGroup;\n if (item.group) {\n lastGroup = item.group;\n }\n const isActive = activeId === item.id;\n const optionDomId =\n useListbox && listboxId ? searchSuggestionOptionDomId(listboxId, item.id) : undefined;\n return (\n <Fragment key={item.id}>\n {showGroup ? (\n <div\n role=\"presentation\"\n className=\"sticky top-0 z-1 bg-slate-950/95 px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm\"\n >\n {item.group}\n </div>\n ) : null}\n <button\n type=\"button\"\n id={optionDomId}\n role={useListbox ? \"option\" : undefined}\n aria-selected={useListbox ? isActive : undefined}\n data-active={isActive ? \"\" : undefined}\n className={cn(rowClassName, isActive ? \"bg-white/5\" : null)}\n onMouseEnter={() => onActiveIdChange?.(item.id)}\n onFocus={() => onActiveIdChange?.(item.id)}\n onClick={() => onSelect(item.id)}\n >\n <span className=\"font-medium text-slate-100\">{item.label}</span>\n {item.description ? (\n <span className=\"truncate text-xs text-slate-500\">{item.description}</span>\n ) : null}\n </button>\n </Fragment>\n );\n })}\n </div>\n </nav>\n );\n}\n\nSearchSuggestionList.displayName = \"SearchSuggestionList\";\n\n","import type { SearchFilterable } from \"./types\";\n\nexport type FilterSearchSuggestionsOptions = {\n /** Maximum number of matches returned. */\n maxResults?: number;\n};\n\n/**\n * Returns items whose label, description, href, or keywords contain the query (case-insensitive).\n * Whitespace-only query matches no items.\n */\nexport function filterSearchSuggestions<T extends SearchFilterable>({\n query,\n items,\n options = { maxResults: 20 },\n}: {\n query: string;\n items: readonly T[];\n options?: FilterSearchSuggestionsOptions;\n}): T[] {\n const maxResults = options.maxResults ?? 20;\n const normalized = query.trim().toLowerCase();\n if (!normalized) {\n return [];\n }\n\n const matches: T[] = [];\n for (const item of items) {\n const isMatch =\n item.label.toLowerCase().includes(normalized) ||\n (item.description?.toLowerCase().includes(normalized)) ||\n (item.href?.toLowerCase().includes(normalized)) ||\n (item.keywords?.some((k) => k.toLowerCase().includes(normalized)));\n\n if (isMatch) {\n matches.push(item);\n if (matches.length >= maxResults || maxResults <= 0) {\n break;\n }\n }\n }\n return matches;\n}\n"]}
@@ -0,0 +1,190 @@
1
+ "use client";
2
+ import { inputVariants } from '../chunk-AOEI4V3W.mjs';
3
+ import { cn } from '../chunk-DFEZH7TC.mjs';
4
+ import { useId, Fragment } from 'react';
5
+ import { jsxs, jsx } from 'react/jsx-runtime';
6
+
7
+ var SearchBar = function SearchBar2({
8
+ value,
9
+ onValueChange,
10
+ leadingSlot,
11
+ className,
12
+ inputClassName,
13
+ appearance = "default",
14
+ inputSize = "md",
15
+ ring = true,
16
+ id,
17
+ onChange,
18
+ disabled,
19
+ type,
20
+ comboboxListboxId,
21
+ comboboxActiveOptionId,
22
+ comboboxExpanded,
23
+ ref,
24
+ ...rest
25
+ }) {
26
+ const generatedId = useId();
27
+ const controlId = id ?? generatedId;
28
+ const combobox = Boolean(comboboxListboxId);
29
+ return /* @__PURE__ */ jsxs(
30
+ "div",
31
+ {
32
+ "data-slot": "search-bar",
33
+ className: cn("relative flex w-full min-w-0 items-center", className),
34
+ children: [
35
+ leadingSlot ? /* @__PURE__ */ jsx(
36
+ "span",
37
+ {
38
+ className: "pointer-events-none absolute left-3 top-1/2 z-1 flex -translate-y-1/2 text-slate-400 [&_svg]:size-4",
39
+ "aria-hidden": true,
40
+ children: leadingSlot
41
+ }
42
+ ) : null,
43
+ /* @__PURE__ */ jsx(
44
+ "input",
45
+ {
46
+ ref,
47
+ id: controlId,
48
+ type: type ?? "search",
49
+ autoComplete: "off",
50
+ spellCheck: false,
51
+ disabled,
52
+ value,
53
+ "data-slot": "search-bar-input",
54
+ className: cn(
55
+ inputVariants({ appearance, size: inputSize, ring, as: "input" }),
56
+ leadingSlot ? "pl-10" : null,
57
+ inputClassName
58
+ ),
59
+ onChange: (event) => {
60
+ onChange?.(event);
61
+ onValueChange?.(event.target.value);
62
+ },
63
+ ...combobox ? {
64
+ role: "combobox",
65
+ "aria-autocomplete": "list",
66
+ "aria-controls": comboboxListboxId,
67
+ "aria-expanded": comboboxExpanded ?? false,
68
+ ...comboboxActiveOptionId ? { "aria-activedescendant": comboboxActiveOptionId } : {}
69
+ } : {},
70
+ ...rest
71
+ }
72
+ )
73
+ ]
74
+ }
75
+ );
76
+ };
77
+ SearchBar.displayName = "SearchBar";
78
+
79
+ // src/ui/search/search-suggestion-utils.ts
80
+ function searchSuggestionOptionDomId(listboxId, itemId) {
81
+ const safe = itemId.replace(/[^a-zA-Z0-9_-]/g, "_");
82
+ return `${listboxId}_opt_${safe}`;
83
+ }
84
+ var rowClassName = "flex w-full flex-col gap-0.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-white/5 focus-visible:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50";
85
+ function SearchSuggestionList({
86
+ items,
87
+ onSelect,
88
+ activeId,
89
+ onActiveIdChange,
90
+ listboxId,
91
+ className,
92
+ listClassName,
93
+ emptyLabel
94
+ }) {
95
+ if (items.length === 0) {
96
+ return /* @__PURE__ */ jsx(
97
+ "div",
98
+ {
99
+ "data-slot": "search-suggestion-list-empty",
100
+ className: cn("px-1 py-6 text-center text-sm text-slate-500", className),
101
+ children: emptyLabel ?? "No matches."
102
+ }
103
+ );
104
+ }
105
+ let lastGroup;
106
+ const useListbox = Boolean(listboxId);
107
+ return /* @__PURE__ */ jsx(
108
+ "nav",
109
+ {
110
+ "data-slot": "search-suggestion-list",
111
+ "aria-label": "Search results",
112
+ className: cn("flex max-h-[min(50vh,360px)] flex-col gap-1 overflow-y-auto pr-1", className),
113
+ children: /* @__PURE__ */ jsx(
114
+ "div",
115
+ {
116
+ ...useListbox ? {
117
+ id: listboxId,
118
+ role: "listbox"
119
+ } : {},
120
+ className: cn("flex flex-col gap-0.5", listClassName),
121
+ children: items.map((item) => {
122
+ const showGroup = item.group && item.group !== lastGroup;
123
+ if (item.group) {
124
+ lastGroup = item.group;
125
+ }
126
+ const isActive = activeId === item.id;
127
+ const optionDomId = useListbox && listboxId ? searchSuggestionOptionDomId(listboxId, item.id) : void 0;
128
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
129
+ showGroup ? /* @__PURE__ */ jsx(
130
+ "div",
131
+ {
132
+ role: "presentation",
133
+ className: "sticky top-0 z-1 bg-slate-950/95 px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm",
134
+ children: item.group
135
+ }
136
+ ) : null,
137
+ /* @__PURE__ */ jsxs(
138
+ "button",
139
+ {
140
+ type: "button",
141
+ id: optionDomId,
142
+ role: useListbox ? "option" : void 0,
143
+ "aria-selected": useListbox ? isActive : void 0,
144
+ "data-active": isActive ? "" : void 0,
145
+ className: cn(rowClassName, isActive ? "bg-white/5" : null),
146
+ onMouseEnter: () => onActiveIdChange?.(item.id),
147
+ onFocus: () => onActiveIdChange?.(item.id),
148
+ onClick: () => onSelect(item.id),
149
+ children: [
150
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-slate-100", children: item.label }),
151
+ item.description ? /* @__PURE__ */ jsx("span", { className: "truncate text-xs text-slate-500", children: item.description }) : null
152
+ ]
153
+ }
154
+ )
155
+ ] }, item.id);
156
+ })
157
+ }
158
+ )
159
+ }
160
+ );
161
+ }
162
+ SearchSuggestionList.displayName = "SearchSuggestionList";
163
+
164
+ // src/ui/search/filter-search-suggestions.ts
165
+ function filterSearchSuggestions({
166
+ query,
167
+ items,
168
+ options = { maxResults: 20 }
169
+ }) {
170
+ const maxResults = options.maxResults ?? 20;
171
+ const normalized = query.trim().toLowerCase();
172
+ if (!normalized) {
173
+ return [];
174
+ }
175
+ const matches = [];
176
+ for (const item of items) {
177
+ const isMatch = item.label.toLowerCase().includes(normalized) || item.description?.toLowerCase().includes(normalized) || item.href?.toLowerCase().includes(normalized) || item.keywords?.some((k) => k.toLowerCase().includes(normalized));
178
+ if (isMatch) {
179
+ matches.push(item);
180
+ if (matches.length >= maxResults || maxResults <= 0) {
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ return matches;
186
+ }
187
+
188
+ export { SearchBar, SearchSuggestionList, filterSearchSuggestions, searchSuggestionOptionDomId };
189
+ //# sourceMappingURL=search.mjs.map
190
+ //# sourceMappingURL=search.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/ui/search/search-bar.tsx","../../src/ui/search/search-suggestion-utils.ts","../../src/ui/search/search-suggestion-list.tsx","../../src/ui/search/filter-search-suggestions.ts"],"names":["SearchBar","jsx","jsxs"],"mappings":"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","file":"search.mjs","sourcesContent":["\"use client\";\n\nimport { forwardRef, useId } from \"react\";\n\nimport { cn } from \"../../lib/utils\";\nimport { inputVariants } from \"../inputs/variants\";\n\nimport type { SearchBarProps } from \"./types\";\n\nexport const SearchBar = function SearchBar(\n {\n value,\n onValueChange,\n leadingSlot,\n className,\n inputClassName,\n appearance = \"default\",\n inputSize = \"md\",\n ring = true,\n id,\n onChange,\n disabled,\n type,\n comboboxListboxId,\n comboboxActiveOptionId,\n comboboxExpanded,\n ref,\n ...rest\n }: SearchBarProps,\n) {\n const generatedId = useId();\n const controlId = id ?? generatedId;\n const combobox = Boolean(comboboxListboxId);\n\n return (\n <div\n data-slot=\"search-bar\"\n className={cn(\"relative flex w-full min-w-0 items-center\", className)}\n >\n {leadingSlot ? (\n <span\n className=\"pointer-events-none absolute left-3 top-1/2 z-1 flex -translate-y-1/2 text-slate-400 [&_svg]:size-4\"\n aria-hidden\n >\n {leadingSlot}\n </span>\n ) : null}\n <input\n ref={ref}\n id={controlId}\n type={type ?? \"search\"}\n autoComplete=\"off\"\n spellCheck={false}\n disabled={disabled}\n value={value}\n data-slot=\"search-bar-input\"\n className={cn(\n inputVariants({ appearance, size: inputSize, ring, as: \"input\" }),\n leadingSlot ? \"pl-10\" : null,\n inputClassName,\n )}\n onChange={(event) => {\n onChange?.(event);\n onValueChange?.(event.target.value);\n }}\n {...(combobox\n ? {\n role: \"combobox\" as const,\n \"aria-autocomplete\": \"list\" as const,\n \"aria-controls\": comboboxListboxId,\n \"aria-expanded\": comboboxExpanded ?? false,\n ...(comboboxActiveOptionId\n ? { \"aria-activedescendant\": comboboxActiveOptionId }\n : {}),\n }\n : {})}\n {...rest}\n />\n </div>\n );\n}\n\nSearchBar.displayName = \"SearchBar\";\n","/**\n * Builds a stable DOM id for a listbox option so `aria-activedescendant` on the combobox\n * input can reference it. Safe for href-like `itemId` strings.\n */\nexport function searchSuggestionOptionDomId(listboxId: string, itemId: string): string {\n const safe = itemId.replace(/[^a-zA-Z0-9_-]/g, \"_\");\n return `${listboxId}_opt_${safe}`;\n}\n","\"use client\";\n\nimport { Fragment } from \"react\";\n\nimport { cn } from \"../../lib/utils\";\nimport { searchSuggestionOptionDomId } from \"./search-suggestion-utils\";\n\nimport type { SearchSuggestionListProps } from \"./types\";\n\nconst rowClassName =\n \"flex w-full flex-col gap-0.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-white/5 focus-visible:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50\";\n\nexport function SearchSuggestionList({\n items,\n onSelect,\n activeId,\n onActiveIdChange,\n listboxId,\n className,\n listClassName,\n emptyLabel,\n}: SearchSuggestionListProps) {\n if (items.length === 0) {\n return (\n <div\n data-slot=\"search-suggestion-list-empty\"\n className={cn(\"px-1 py-6 text-center text-sm text-slate-500\", className)}\n >\n {emptyLabel ?? \"No matches.\"}\n </div>\n );\n }\n\n let lastGroup: string | undefined;\n const useListbox = Boolean(listboxId);\n\n return (\n <nav\n data-slot=\"search-suggestion-list\"\n aria-label=\"Search results\"\n className={cn(\"flex max-h-[min(50vh,360px)] flex-col gap-1 overflow-y-auto pr-1\", className)}\n >\n <div\n {...(useListbox\n ? {\n id: listboxId,\n role: \"listbox\" as const,\n }\n : {})}\n className={cn(\"flex flex-col gap-0.5\", listClassName)}\n >\n {items.map((item) => {\n const showGroup = item.group && item.group !== lastGroup;\n if (item.group) {\n lastGroup = item.group;\n }\n const isActive = activeId === item.id;\n const optionDomId =\n useListbox && listboxId ? searchSuggestionOptionDomId(listboxId, item.id) : undefined;\n return (\n <Fragment key={item.id}>\n {showGroup ? (\n <div\n role=\"presentation\"\n className=\"sticky top-0 z-1 bg-slate-950/95 px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm\"\n >\n {item.group}\n </div>\n ) : null}\n <button\n type=\"button\"\n id={optionDomId}\n role={useListbox ? \"option\" : undefined}\n aria-selected={useListbox ? isActive : undefined}\n data-active={isActive ? \"\" : undefined}\n className={cn(rowClassName, isActive ? \"bg-white/5\" : null)}\n onMouseEnter={() => onActiveIdChange?.(item.id)}\n onFocus={() => onActiveIdChange?.(item.id)}\n onClick={() => onSelect(item.id)}\n >\n <span className=\"font-medium text-slate-100\">{item.label}</span>\n {item.description ? (\n <span className=\"truncate text-xs text-slate-500\">{item.description}</span>\n ) : null}\n </button>\n </Fragment>\n );\n })}\n </div>\n </nav>\n );\n}\n\nSearchSuggestionList.displayName = \"SearchSuggestionList\";\n\n","import type { SearchFilterable } from \"./types\";\n\nexport type FilterSearchSuggestionsOptions = {\n /** Maximum number of matches returned. */\n maxResults?: number;\n};\n\n/**\n * Returns items whose label, description, href, or keywords contain the query (case-insensitive).\n * Whitespace-only query matches no items.\n */\nexport function filterSearchSuggestions<T extends SearchFilterable>({\n query,\n items,\n options = { maxResults: 20 },\n}: {\n query: string;\n items: readonly T[];\n options?: FilterSearchSuggestionsOptions;\n}): T[] {\n const maxResults = options.maxResults ?? 20;\n const normalized = query.trim().toLowerCase();\n if (!normalized) {\n return [];\n }\n\n const matches: T[] = [];\n for (const item of items) {\n const isMatch =\n item.label.toLowerCase().includes(normalized) ||\n (item.description?.toLowerCase().includes(normalized)) ||\n (item.href?.toLowerCase().includes(normalized)) ||\n (item.keywords?.some((k) => k.toLowerCase().includes(normalized)));\n\n if (isMatch) {\n matches.push(item);\n if (matches.length >= maxResults || maxResults <= 0) {\n break;\n }\n }\n }\n return matches;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zentauri-ui/zentauri-components",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "React + Tailwind UI kit with ESM/CJS builds, per-entry exports, and a zentauri-components / zentauri-ui CLI to vendor UI or hook source into your app",
5
5
  "license": "MIT",
6
6
  "files": ["dist", "src/ui", "src/lib", "src/hooks", "cli"],
@@ -45,8 +45,8 @@
45
45
  "test:watch": "vitest"
46
46
  },
47
47
  "peerDependencies": {
48
- "react": ">=18",
49
- "react-dom": ">=18",
48
+ "react": ">18",
49
+ "react-dom": ">18",
50
50
  "class-variance-authority": "*",
51
51
  "clsx": "*",
52
52
  "tailwind-merge": "*",
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { filterSearchSuggestions } from "./filter-search-suggestions";
4
+ import type { SearchFilterable } from "./types";
5
+
6
+ const sampleItems: SearchFilterable[] = [
7
+ { id: "1", label: "Installation", href: "/preview/installation", keywords: ["npm", "setup"] },
8
+ { id: "2", label: "Modal", href: "/preview/components/modal" },
9
+ { id: "3", label: "GitHub", href: "https://example.com/repo", keywords: ["github", "source"] },
10
+ ];
11
+
12
+ describe("filterSearchSuggestions", () => {
13
+ it("returns empty list for whitespace-only query", () => {
14
+ expect(filterSearchSuggestions({ query: " ", items: sampleItems })).toEqual([]);
15
+ });
16
+
17
+ it("returns empty list for empty query", () => {
18
+ expect(filterSearchSuggestions({ query: "", items: sampleItems })).toEqual([]);
19
+ });
20
+
21
+ it("matches label case-insensitively", () => {
22
+ expect(filterSearchSuggestions({ query: "modal", items: sampleItems })).toEqual([
23
+ { id: "2", label: "Modal", href: "/preview/components/modal" },
24
+ ]);
25
+ });
26
+
27
+ it("matches href substring", () => {
28
+ expect(filterSearchSuggestions({ query: "installation", items: sampleItems })).toEqual([
29
+ { id: "1", label: "Installation", href: "/preview/installation", keywords: ["npm", "setup"] },
30
+ ]);
31
+ });
32
+
33
+ it("matches keywords", () => {
34
+ expect(filterSearchSuggestions({ query: "github", items: sampleItems })).toEqual([
35
+ { id: "3", label: "GitHub", href: "https://example.com/repo", keywords: ["github", "source"] },
36
+ ]);
37
+ });
38
+
39
+ it("respects maxResults", () => {
40
+ const many: SearchFilterable[] = Array.from({ length: 30 }, (_, index) => ({
41
+ id: `x-${index}`,
42
+ label: `Item ${index}`,
43
+ href: `/x/${index}`,
44
+ keywords: ["alpha"],
45
+ }));
46
+ expect(filterSearchSuggestions({ query: "alpha", items: many, options: { maxResults: 5 } })).toHaveLength(5);
47
+ });
48
+ });
@@ -0,0 +1,43 @@
1
+ import type { SearchFilterable } from "./types";
2
+
3
+ export type FilterSearchSuggestionsOptions = {
4
+ /** Maximum number of matches returned. */
5
+ maxResults?: number;
6
+ };
7
+
8
+ /**
9
+ * Returns items whose label, description, href, or keywords contain the query (case-insensitive).
10
+ * Whitespace-only query matches no items.
11
+ */
12
+ export function filterSearchSuggestions<T extends SearchFilterable>({
13
+ query,
14
+ items,
15
+ options = { maxResults: 20 },
16
+ }: {
17
+ query: string;
18
+ items: readonly T[];
19
+ options?: FilterSearchSuggestionsOptions;
20
+ }): T[] {
21
+ const maxResults = options.maxResults ?? 20;
22
+ const normalized = query.trim().toLowerCase();
23
+ if (!normalized) {
24
+ return [];
25
+ }
26
+
27
+ const matches: T[] = [];
28
+ for (const item of items) {
29
+ const isMatch =
30
+ item.label.toLowerCase().includes(normalized) ||
31
+ (item.description?.toLowerCase().includes(normalized)) ||
32
+ (item.href?.toLowerCase().includes(normalized)) ||
33
+ (item.keywords?.some((k) => k.toLowerCase().includes(normalized)));
34
+
35
+ if (isMatch) {
36
+ matches.push(item);
37
+ if (matches.length >= maxResults || maxResults <= 0) {
38
+ break;
39
+ }
40
+ }
41
+ }
42
+ return matches;
43
+ }
@@ -0,0 +1,11 @@
1
+ export { SearchBar } from "./search-bar";
2
+ export { SearchSuggestionList } from "./search-suggestion-list";
3
+ export { searchSuggestionOptionDomId } from "./search-suggestion-utils";
4
+ export { filterSearchSuggestions } from "./filter-search-suggestions";
5
+ export type {
6
+ SearchBarProps,
7
+ SearchSuggestionItem,
8
+ SearchSuggestionListProps,
9
+ SearchFilterable,
10
+ } from "./types";
11
+ export type { FilterSearchSuggestionsOptions } from "./filter-search-suggestions";
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useId } from "react";
4
+
5
+ import { cn } from "../../lib/utils";
6
+ import { inputVariants } from "../inputs/variants";
7
+
8
+ import type { SearchBarProps } from "./types";
9
+
10
+ export const SearchBar = function SearchBar(
11
+ {
12
+ value,
13
+ onValueChange,
14
+ leadingSlot,
15
+ className,
16
+ inputClassName,
17
+ appearance = "default",
18
+ inputSize = "md",
19
+ ring = true,
20
+ id,
21
+ onChange,
22
+ disabled,
23
+ type,
24
+ comboboxListboxId,
25
+ comboboxActiveOptionId,
26
+ comboboxExpanded,
27
+ ref,
28
+ ...rest
29
+ }: SearchBarProps,
30
+ ) {
31
+ const generatedId = useId();
32
+ const controlId = id ?? generatedId;
33
+ const combobox = Boolean(comboboxListboxId);
34
+
35
+ return (
36
+ <div
37
+ data-slot="search-bar"
38
+ className={cn("relative flex w-full min-w-0 items-center", className)}
39
+ >
40
+ {leadingSlot ? (
41
+ <span
42
+ className="pointer-events-none absolute left-3 top-1/2 z-1 flex -translate-y-1/2 text-slate-400 [&_svg]:size-4"
43
+ aria-hidden
44
+ >
45
+ {leadingSlot}
46
+ </span>
47
+ ) : null}
48
+ <input
49
+ ref={ref}
50
+ id={controlId}
51
+ type={type ?? "search"}
52
+ autoComplete="off"
53
+ spellCheck={false}
54
+ disabled={disabled}
55
+ value={value}
56
+ data-slot="search-bar-input"
57
+ className={cn(
58
+ inputVariants({ appearance, size: inputSize, ring, as: "input" }),
59
+ leadingSlot ? "pl-10" : null,
60
+ inputClassName,
61
+ )}
62
+ onChange={(event) => {
63
+ onChange?.(event);
64
+ onValueChange?.(event.target.value);
65
+ }}
66
+ {...(combobox
67
+ ? {
68
+ role: "combobox" as const,
69
+ "aria-autocomplete": "list" as const,
70
+ "aria-controls": comboboxListboxId,
71
+ "aria-expanded": comboboxExpanded ?? false,
72
+ ...(comboboxActiveOptionId
73
+ ? { "aria-activedescendant": comboboxActiveOptionId }
74
+ : {}),
75
+ }
76
+ : {})}
77
+ {...rest}
78
+ />
79
+ </div>
80
+ );
81
+ }
82
+
83
+ SearchBar.displayName = "SearchBar";
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { Fragment } from "react";
4
+
5
+ import { cn } from "../../lib/utils";
6
+ import { searchSuggestionOptionDomId } from "./search-suggestion-utils";
7
+
8
+ import type { SearchSuggestionListProps } from "./types";
9
+
10
+ const rowClassName =
11
+ "flex w-full flex-col gap-0.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-white/5 focus-visible:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50";
12
+
13
+ export function SearchSuggestionList({
14
+ items,
15
+ onSelect,
16
+ activeId,
17
+ onActiveIdChange,
18
+ listboxId,
19
+ className,
20
+ listClassName,
21
+ emptyLabel,
22
+ }: SearchSuggestionListProps) {
23
+ if (items.length === 0) {
24
+ return (
25
+ <div
26
+ data-slot="search-suggestion-list-empty"
27
+ className={cn("px-1 py-6 text-center text-sm text-slate-500", className)}
28
+ >
29
+ {emptyLabel ?? "No matches."}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ let lastGroup: string | undefined;
35
+ const useListbox = Boolean(listboxId);
36
+
37
+ return (
38
+ <nav
39
+ data-slot="search-suggestion-list"
40
+ aria-label="Search results"
41
+ className={cn("flex max-h-[min(50vh,360px)] flex-col gap-1 overflow-y-auto pr-1", className)}
42
+ >
43
+ <div
44
+ {...(useListbox
45
+ ? {
46
+ id: listboxId,
47
+ role: "listbox" as const,
48
+ }
49
+ : {})}
50
+ className={cn("flex flex-col gap-0.5", listClassName)}
51
+ >
52
+ {items.map((item) => {
53
+ const showGroup = item.group && item.group !== lastGroup;
54
+ if (item.group) {
55
+ lastGroup = item.group;
56
+ }
57
+ const isActive = activeId === item.id;
58
+ const optionDomId =
59
+ useListbox && listboxId ? searchSuggestionOptionDomId(listboxId, item.id) : undefined;
60
+ return (
61
+ <Fragment key={item.id}>
62
+ {showGroup ? (
63
+ <div
64
+ role="presentation"
65
+ className="sticky top-0 z-1 bg-slate-950/95 px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-500 backdrop-blur-sm"
66
+ >
67
+ {item.group}
68
+ </div>
69
+ ) : null}
70
+ <button
71
+ type="button"
72
+ id={optionDomId}
73
+ role={useListbox ? "option" : undefined}
74
+ aria-selected={useListbox ? isActive : undefined}
75
+ data-active={isActive ? "" : undefined}
76
+ className={cn(rowClassName, isActive ? "bg-white/5" : null)}
77
+ onMouseEnter={() => onActiveIdChange?.(item.id)}
78
+ onFocus={() => onActiveIdChange?.(item.id)}
79
+ onClick={() => onSelect(item.id)}
80
+ >
81
+ <span className="font-medium text-slate-100">{item.label}</span>
82
+ {item.description ? (
83
+ <span className="truncate text-xs text-slate-500">{item.description}</span>
84
+ ) : null}
85
+ </button>
86
+ </Fragment>
87
+ );
88
+ })}
89
+ </div>
90
+ </nav>
91
+ );
92
+ }
93
+
94
+ SearchSuggestionList.displayName = "SearchSuggestionList";
95
+
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { searchSuggestionOptionDomId } from "./search-suggestion-utils";
4
+
5
+ describe("searchSuggestionOptionDomId", () => {
6
+ it("prefixes listbox id and sanitizes item id", () => {
7
+ expect(searchSuggestionOptionDomId(":lb:", "/a/b")).toBe(":lb:_opt__a_b");
8
+ });
9
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Builds a stable DOM id for a listbox option so `aria-activedescendant` on the combobox
3
+ * input can reference it. Safe for href-like `itemId` strings.
4
+ */
5
+ export function searchSuggestionOptionDomId(listboxId: string, itemId: string): string {
6
+ const safe = itemId.replace(/[^a-zA-Z0-9_-]/g, "_");
7
+ return `${listboxId}_opt_${safe}`;
8
+ }
@@ -0,0 +1,52 @@
1
+ import type { InputHTMLAttributes, ReactNode, Ref } from "react";
2
+
3
+ import type { VariantProps } from "class-variance-authority";
4
+
5
+ import type { inputVariants } from "../inputs/variants";
6
+
7
+ export type SearchBarProps = Omit<
8
+ InputHTMLAttributes<HTMLInputElement>,
9
+ "size" | "children" | "role"
10
+ > & {
11
+ value: string;
12
+ onValueChange?: (value: string) => void;
13
+ leadingSlot?: ReactNode;
14
+ inputClassName?: string;
15
+ appearance?: VariantProps<typeof inputVariants>["appearance"];
16
+ inputSize?: VariantProps<typeof inputVariants>["size"];
17
+ ring?: VariantProps<typeof inputVariants>["ring"];
18
+ /** When set, the input exposes combobox semantics wired to a `role="listbox"` with this id. */
19
+ comboboxListboxId?: string;
20
+ /** Element id of the active option (from `searchSuggestionOptionDomId`) for `aria-activedescendant`. */
21
+ comboboxActiveOptionId?: string;
22
+ /** Whether the suggestion list is visibly expanded (controls `aria-expanded`). */
23
+ comboboxExpanded?: boolean;
24
+ ref?: Ref<HTMLInputElement>;
25
+ };
26
+
27
+ export type SearchSuggestionItem = {
28
+ id: string;
29
+ label: string;
30
+ description?: string;
31
+ group?: string;
32
+ };
33
+
34
+ export type SearchSuggestionListProps = {
35
+ items: readonly SearchSuggestionItem[];
36
+ onSelect: (id: string) => void;
37
+ activeId?: string;
38
+ onActiveIdChange?: (id: string | undefined) => void;
39
+ /** Pass the same id as `comboboxListboxId` on `SearchBar` for ARIA wiring. */
40
+ listboxId?: string;
41
+ className?: string;
42
+ listClassName?: string;
43
+ emptyLabel?: ReactNode;
44
+ };
45
+
46
+ export type SearchFilterable = {
47
+ id: string;
48
+ label: string;
49
+ description?: string;
50
+ keywords?: readonly string[];
51
+ href?: string;
52
+ };