@tensor_1/react 2.24.0 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
4
+ import { cx } from "../cx.js";
5
+ import { useAnchoredPosition } from "../hooks/useAnchoredPosition.js";
6
+ import { searchSegments } from "../search-suggest-model.js";
7
+ import { Spinner } from "./Spinner.js";
8
+ const SearchIcon = () => /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
9
+ /* @__PURE__ */ jsx("circle", { cx: "11", cy: "11", r: "7" }),
10
+ /* @__PURE__ */ jsx("path", { d: "m20 20-3.5-3.5" })
11
+ ] });
12
+ const ClearIcon = () => /* @__PURE__ */ jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" }) });
13
+ function Emphasised({ text, query }) {
14
+ return /* @__PURE__ */ jsx(Fragment, { children: searchSegments(text, query).map(
15
+ (segment, index) => segment.match ? /* @__PURE__ */ jsx("mark", { className: "uix-search-suggest__match", children: segment.text }, index) : /* @__PURE__ */ jsx("span", { children: segment.text }, index)
16
+ ) });
17
+ }
18
+ const keepFocus = (event) => event.preventDefault();
19
+ const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect;
20
+ function SearchSuggest({
21
+ value,
22
+ onValueChange,
23
+ options,
24
+ onSelect,
25
+ label,
26
+ placeholder,
27
+ highlight,
28
+ heading,
29
+ headingAction,
30
+ footer,
31
+ loading = false,
32
+ loadingLabel,
33
+ empty,
34
+ error,
35
+ shortcutHint,
36
+ clearLabel = "Clear search",
37
+ status,
38
+ size = "md",
39
+ open: openProp,
40
+ strategy = "absolute",
41
+ onOpenChange,
42
+ inputRef,
43
+ autoFocus,
44
+ name,
45
+ className,
46
+ onBlur,
47
+ ...props
48
+ }) {
49
+ const id = useId();
50
+ const listboxId = `${id}-listbox`;
51
+ const headingId = `${id}-heading`;
52
+ const rootRef = useRef(null);
53
+ const fieldRef = useRef(null);
54
+ useImperativeHandle(inputRef, () => fieldRef.current, []);
55
+ const [openState, setOpenState] = useState(false);
56
+ const open = openProp ?? openState;
57
+ const setOpen = (next) => {
58
+ if (openProp === void 0) setOpenState(next);
59
+ if (next !== open) onOpenChange?.(next);
60
+ };
61
+ const [focused, setFocused] = useState(false);
62
+ const [activeState, setActive] = useState(-1);
63
+ const itemCount = options.length + (footer ? 1 : 0);
64
+ const active = activeState < itemCount ? activeState : -1;
65
+ const optionKey = options.map((option) => option.id).join("|");
66
+ useEffect(() => setActive(-1), [optionKey]);
67
+ const hasText = value.trim().length > 0;
68
+ const showEmpty = hasText && !loading && !error && options.length === 0 && empty != null;
69
+ const showLoading = loading && options.length === 0;
70
+ const visible = open && (itemCount > 0 || showLoading || showEmpty || error != null);
71
+ const optionId = (index) => `${id}-option-${index}`;
72
+ useEffect(() => {
73
+ if (!visible || active < 0) return;
74
+ const node = document.getElementById(optionId(active));
75
+ node?.scrollIntoView?.({ block: "nearest" });
76
+ }, [active, visible]);
77
+ const choose = (index) => {
78
+ const option = options[index];
79
+ if (option) onSelect(option.id, option);
80
+ else if (footer && index === options.length) footer.onSelect();
81
+ else return;
82
+ setOpen(false);
83
+ setActive(-1);
84
+ };
85
+ const handleKeyDown = (event) => {
86
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
87
+ switch (event.key) {
88
+ case "ArrowDown":
89
+ case "ArrowUp": {
90
+ event.preventDefault();
91
+ if (!itemCount) {
92
+ setOpen(true);
93
+ return;
94
+ }
95
+ const down = event.key === "ArrowDown";
96
+ if (!visible) {
97
+ setOpen(true);
98
+ setActive(down ? 0 : itemCount - 1);
99
+ return;
100
+ }
101
+ setActive((stored) => {
102
+ const current = stored < itemCount ? stored : -1;
103
+ if (current < 0) return down ? 0 : itemCount - 1;
104
+ return (current + (down ? 1 : -1) + itemCount) % itemCount;
105
+ });
106
+ return;
107
+ }
108
+ case "Home":
109
+ case "End":
110
+ if (visible && active >= 0 && itemCount) {
111
+ event.preventDefault();
112
+ setActive(event.key === "Home" ? 0 : itemCount - 1);
113
+ }
114
+ return;
115
+ case "Enter":
116
+ if (visible && active >= 0) {
117
+ event.preventDefault();
118
+ choose(active);
119
+ return;
120
+ }
121
+ if (visible && hasText && options.length) {
122
+ event.preventDefault();
123
+ choose(0);
124
+ }
125
+ return;
126
+ case "Escape":
127
+ if (visible) {
128
+ event.preventDefault();
129
+ event.stopPropagation();
130
+ setOpen(false);
131
+ setActive(-1);
132
+ } else if (value) {
133
+ event.preventDefault();
134
+ event.stopPropagation();
135
+ onValueChange("");
136
+ }
137
+ return;
138
+ default:
139
+ }
140
+ };
141
+ const handleBlur = (event) => {
142
+ onBlur?.(event);
143
+ if (!rootRef.current?.contains(event.relatedTarget)) {
144
+ setFocused(false);
145
+ setOpen(false);
146
+ setActive(-1);
147
+ }
148
+ };
149
+ const clear = () => {
150
+ onValueChange("");
151
+ setActive(-1);
152
+ fieldRef.current?.focus();
153
+ };
154
+ const activeId = visible && active >= 0 ? optionId(active) : void 0;
155
+ const fieldBoxRef = useRef(null);
156
+ const popupRef = useRef(null);
157
+ const floating = strategy === "fixed";
158
+ useIsomorphicLayoutEffect(() => {
159
+ const popup = popupRef.current;
160
+ const box = fieldBoxRef.current;
161
+ if (!floating || !visible || !popup || !box) return;
162
+ const size2 = () => {
163
+ popup.style.width = `${box.offsetWidth}px`;
164
+ };
165
+ size2();
166
+ window.addEventListener("resize", size2);
167
+ return () => window.removeEventListener("resize", size2);
168
+ }, [floating, visible]);
169
+ useAnchoredPosition(floating ? fieldBoxRef : null, popupRef, { open: floating && visible, placement: "bottom-start", offset: 4 });
170
+ return /* @__PURE__ */ jsxs(
171
+ "div",
172
+ {
173
+ ref: rootRef,
174
+ className: cx("uix-search-suggest", size === "lg" && "uix-search-suggest--lg", className),
175
+ "data-open": visible || void 0,
176
+ onBlur: handleBlur,
177
+ ...props,
178
+ children: [
179
+ /* @__PURE__ */ jsxs("div", { ref: fieldBoxRef, className: "uix-search-suggest__field", children: [
180
+ /* @__PURE__ */ jsx("span", { className: "uix-search-suggest__lead", "aria-hidden": "true", children: /* @__PURE__ */ jsx(SearchIcon, {}) }),
181
+ /* @__PURE__ */ jsx(
182
+ "input",
183
+ {
184
+ ref: fieldRef,
185
+ className: "uix-input uix-search-suggest__input",
186
+ type: "text",
187
+ role: "combobox",
188
+ "aria-label": label,
189
+ "aria-autocomplete": "list",
190
+ "aria-expanded": visible,
191
+ "aria-controls": listboxId,
192
+ "aria-activedescendant": activeId,
193
+ "aria-busy": loading || void 0,
194
+ autoComplete: "off",
195
+ spellCheck: false,
196
+ enterKeyHint: "search",
197
+ name,
198
+ value,
199
+ placeholder,
200
+ autoFocus,
201
+ onChange: (event) => {
202
+ onValueChange(event.target.value);
203
+ setActive(-1);
204
+ setOpen(true);
205
+ },
206
+ onFocus: () => {
207
+ setFocused(true);
208
+ setOpen(true);
209
+ },
210
+ onClick: () => setOpen(true),
211
+ onKeyDown: handleKeyDown
212
+ }
213
+ ),
214
+ /* @__PURE__ */ jsxs("span", { className: "uix-search-suggest__trail", children: [
215
+ loading && /* @__PURE__ */ jsx(Spinner, { className: "uix-search-suggest__spinner", label: loadingLabel }),
216
+ !loading && value && /* @__PURE__ */ jsx("button", { type: "button", className: "uix-search-suggest__clear", "aria-label": clearLabel, onMouseDown: keepFocus, onClick: clear, children: /* @__PURE__ */ jsx(ClearIcon, {}) }),
217
+ !loading && !value && !focused && shortcutHint && /* @__PURE__ */ jsx("kbd", { className: "uix-kbd", "aria-hidden": "true", children: shortcutHint })
218
+ ] })
219
+ ] }),
220
+ /* @__PURE__ */ jsxs("div", { ref: popupRef, className: "uix-search-suggest__popup", "data-strategy": floating ? "fixed" : void 0, hidden: !visible, children: [
221
+ heading && options.length > 0 && /* @__PURE__ */ jsxs("div", { className: "uix-search-suggest__header", children: [
222
+ /* @__PURE__ */ jsx("span", { className: "uix-search-suggest__heading", id: headingId, children: heading }),
223
+ headingAction && /* @__PURE__ */ jsx("button", { type: "button", className: "uix-search-suggest__header-action", onMouseDown: keepFocus, onClick: headingAction.onSelect, children: headingAction.label })
224
+ ] }),
225
+ /* @__PURE__ */ jsxs(
226
+ "ul",
227
+ {
228
+ id: listboxId,
229
+ role: "listbox",
230
+ className: "uix-search-suggest__list",
231
+ "aria-label": heading && options.length > 0 ? void 0 : label,
232
+ "aria-labelledby": heading && options.length > 0 ? headingId : void 0,
233
+ "aria-busy": loading || void 0,
234
+ children: [
235
+ options.map((option, index) => {
236
+ const crumbs = option.meta ?? [];
237
+ const descId = option.description ? `${optionId(index)}-desc` : void 0;
238
+ return /* @__PURE__ */ jsxs(
239
+ "li",
240
+ {
241
+ id: optionId(index),
242
+ role: "option",
243
+ "aria-selected": index === active,
244
+ "aria-label": crumbs.length ? `${option.title}, ${crumbs.join(" \u203A ")}` : option.title,
245
+ "aria-describedby": descId,
246
+ className: "uix-search-suggest__option",
247
+ "data-active": index === active || void 0,
248
+ onMouseDown: keepFocus,
249
+ onMouseMove: () => {
250
+ if (active !== index) setActive(index);
251
+ },
252
+ onClick: () => choose(index),
253
+ children: [
254
+ option.icon != null && /* @__PURE__ */ jsx("span", { className: "uix-search-suggest__icon", "aria-hidden": "true", children: option.icon }),
255
+ /* @__PURE__ */ jsxs("span", { className: "uix-search-suggest__body", children: [
256
+ /* @__PURE__ */ jsx("span", { className: "uix-search-suggest__title", children: /* @__PURE__ */ jsx(Emphasised, { text: option.title, query: highlight ?? value }) }),
257
+ crumbs.length > 0 && /* @__PURE__ */ jsx("span", { className: "uix-search-suggest__meta", title: crumbs.join(" \u203A "), children: crumbs.map((crumb, crumbIndex) => /* @__PURE__ */ jsx("span", { className: "uix-search-suggest__crumb", children: crumb }, crumbIndex)) }),
258
+ option.description && /* @__PURE__ */ jsx("span", { id: descId, className: "uix-search-suggest__desc", children: /* @__PURE__ */ jsx(Emphasised, { text: option.description, query: highlight ?? value }) })
259
+ ] })
260
+ ]
261
+ },
262
+ option.id
263
+ );
264
+ }),
265
+ footer && /* @__PURE__ */ jsx(
266
+ "li",
267
+ {
268
+ id: optionId(options.length),
269
+ role: "option",
270
+ "aria-selected": active === options.length,
271
+ className: "uix-search-suggest__footer",
272
+ "data-active": active === options.length || void 0,
273
+ onMouseDown: keepFocus,
274
+ onMouseMove: () => {
275
+ if (active !== options.length) setActive(options.length);
276
+ },
277
+ onClick: () => choose(options.length),
278
+ children: footer.label
279
+ }
280
+ )
281
+ ]
282
+ }
283
+ ),
284
+ showLoading && // The field's spinner is the announced status; this row is the visual echo in the list.
285
+ /* @__PURE__ */ jsxs("div", { className: "uix-search-suggest__state", "aria-hidden": "true", children: [
286
+ /* @__PURE__ */ jsx("span", { className: "uix-spinner" }),
287
+ loadingLabel && /* @__PURE__ */ jsx("span", { children: loadingLabel })
288
+ ] }),
289
+ error != null && options.length === 0 && !loading && /* @__PURE__ */ jsx("div", { className: "uix-search-suggest__state", children: error }),
290
+ showEmpty && /* @__PURE__ */ jsx("div", { className: "uix-search-suggest__state", children: empty })
291
+ ] }),
292
+ /* @__PURE__ */ jsx("span", { className: "uix-visually-hidden", role: "status", "aria-live": "polite", children: status ?? "" })
293
+ ]
294
+ }
295
+ );
296
+ }
297
+ export {
298
+ SearchSuggest
299
+ };