@rovula/ui 0.1.41 → 0.1.43

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,379 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ KeyboardEvent,
5
+ ReactNode,
6
+ forwardRef,
7
+ useCallback,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
12
+ import TextInput, { type InputProps } from "../TextInput/TextInput";
13
+ import Loading from "../Loading/Loading";
14
+ import Text from "../Text/Text";
15
+ import Icon from "../Icon/Icon";
16
+ import { cn } from "@/utils/cn";
17
+ import { menuItemBaseStyles } from "../Dropdown/Dropdown";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type AutoCompleteOption = {
24
+ value: string;
25
+ label: string;
26
+ };
27
+
28
+ /**
29
+ * InputProps that AutoComplete manages internally.
30
+ * These are excluded from the public API so callers cannot accidentally
31
+ * override internal event wiring or ARIA attributes.
32
+ */
33
+ type OmittedInputProps =
34
+ | "value"
35
+ | "onChange"
36
+ // | "onBlur"
37
+ // | "onFocus"
38
+ | "onKeyDown"
39
+ | "onSelect"
40
+ | "role"
41
+ | "aria-expanded"
42
+ | "aria-haspopup"
43
+ | "aria-autocomplete"
44
+ | "aria-activedescendant"
45
+ | "aria-controls"
46
+ | "autoComplete"
47
+ | "hasClearIcon";
48
+
49
+ export type AutoCompleteProps<
50
+ T extends AutoCompleteOption = AutoCompleteOption,
51
+ > = {
52
+ // ── AutoComplete-specific ─────────────────────────────────────────────────
53
+
54
+ /** Options provided by caller (already filtered/fetched externally) */
55
+ options: T[];
56
+
57
+ /** Controlled value — the current input text */
58
+ value?: string;
59
+
60
+ /**
61
+ * Called on every change: typing or clearing.
62
+ * Parent should update `value` from this.
63
+ */
64
+ onChange?: (value: string) => void;
65
+
66
+ /**
67
+ * Called only when user explicitly selects an option from the list.
68
+ * Receives the full option object (including any domain-specific fields).
69
+ */
70
+ onSelect?: (option: T) => void;
71
+
72
+ onBlur?: () => void;
73
+
74
+ /** Called with the current query when the user types */
75
+ onSearch?: (query: string) => void;
76
+
77
+ /** Show a loading spinner inside the dropdown */
78
+ loading?: boolean;
79
+
80
+ /** Text shown when options is empty and not loading */
81
+ noOptionsText?: string;
82
+
83
+ /**
84
+ * When true, show the noOptionsText message when options is empty.
85
+ * Defaults to false (popover stays closed when no options).
86
+ */
87
+ showNoOptions?: boolean;
88
+
89
+ /**
90
+ * Custom render for each option item.
91
+ * Receives the option and whether it is currently selected.
92
+ * The wrapper button (styles + click handler) is provided by AutoComplete.
93
+ */
94
+ renderOption?: (option: T, isSelected: boolean) => ReactNode;
95
+
96
+ /**
97
+ * Override client-side filtering.
98
+ * Pass `(x) => x` to disable filtering entirely (when results come from an API).
99
+ * Defaults to identity (no filtering).
100
+ */
101
+ filterOptions?: (options: T[]) => T[];
102
+
103
+ /**
104
+ * Render the options list via a React portal so it escapes containers
105
+ * with `overflow: hidden/auto`.
106
+ * Set to false when inside a Dialog — portal content is blocked by
107
+ * Radix Dialog's focus trap and aria-modal, making items unclickable.
108
+ * Defaults to true.
109
+ */
110
+ portal?: boolean;
111
+
112
+ /** Extra className applied to the options list container */
113
+ listboxClassName?: string;
114
+
115
+ /** Extra inline styles applied to the options list container */
116
+ listboxStyle?: React.CSSProperties;
117
+
118
+ /** Extra inline styles applied to the Popover content wrapper (portal mode only) */
119
+ popoverStyle?: React.CSSProperties;
120
+
121
+ /** Extra className applied to each option item */
122
+ optionClassName?: string;
123
+
124
+ /** Extra inline styles applied to each option item */
125
+ optionStyle?: React.CSSProperties;
126
+
127
+ "data-testid"?: string;
128
+
129
+ // ── TextInput passthrough ─────────────────────────────────────────────────
130
+ // All InputProps are supported except the ones AutoComplete manages itself.
131
+ // This includes: className, style, label, placeholder, error, errorMessage,
132
+ // helperText, warningMessage, required, disabled, fullwidth, size, rounded,
133
+ // variant, startIcon, endIcon, labelClassName, classes, etc.
134
+ } & Omit<InputProps, OmittedInputProps>;
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // AutoComplete
138
+ // ---------------------------------------------------------------------------
139
+
140
+ function AutoCompleteInner<T extends AutoCompleteOption = AutoCompleteOption>(
141
+ {
142
+ // AutoComplete-specific props
143
+ options,
144
+ value = "",
145
+ onChange,
146
+ onSelect,
147
+ onBlur,
148
+ onSearch,
149
+ loading = false,
150
+ noOptionsText = "No results",
151
+ showNoOptions = false,
152
+ renderOption,
153
+ filterOptions = (x) => x,
154
+ portal = true,
155
+ listboxClassName,
156
+ listboxStyle,
157
+ popoverStyle,
158
+ optionClassName,
159
+ optionStyle,
160
+ "data-testid": testId,
161
+ // InputProps with explicit defaults (rest passes everything else through)
162
+ id,
163
+ label,
164
+ fullwidth = true,
165
+ size = "md",
166
+ rounded = "normal",
167
+ variant = "outline",
168
+ disabled,
169
+ ...rest
170
+ }: AutoCompleteProps<T>,
171
+ ref: React.ForwardedRef<HTMLInputElement>,
172
+ ) {
173
+ const [open, setOpen] = useState(false);
174
+ const [activeIndex, setActiveIndex] = useState(-1);
175
+ const inputRef = useRef<HTMLInputElement>(null);
176
+
177
+ const filteredOptions = filterOptions(options);
178
+ const showPopover =
179
+ open && (loading || filteredOptions.length > 0 || showNoOptions);
180
+
181
+ const commitSelection = useCallback(
182
+ (option: T) => {
183
+ onChange?.(option.value);
184
+ onSelect?.(option);
185
+ setOpen(false);
186
+ setActiveIndex(-1);
187
+ },
188
+ [onChange, onSelect],
189
+ );
190
+
191
+ const handleInputChange = useCallback(
192
+ (e: React.ChangeEvent<HTMLInputElement>) => {
193
+ const query = e.target.value;
194
+ onChange?.(query);
195
+ onSearch?.(query);
196
+ setOpen(true);
197
+ setActiveIndex(-1);
198
+ },
199
+ [onChange, onSearch],
200
+ );
201
+
202
+ const handleFocus = useCallback(
203
+ (e: React.FocusEvent<HTMLInputElement>) => {
204
+ setOpen(true);
205
+ rest?.onFocus?.(e);
206
+ },
207
+ [rest?.onFocus],
208
+ );
209
+
210
+ const handleBlur = useCallback(() => {
211
+ // Delay so option button onClick fires before popover closes
212
+ setTimeout(() => {
213
+ setOpen(false);
214
+ setActiveIndex(-1);
215
+ onBlur?.();
216
+ }, 150);
217
+ }, [onBlur]);
218
+
219
+ const handleKeyDown = useCallback(
220
+ (e: KeyboardEvent<HTMLInputElement>) => {
221
+ if (!open) {
222
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") setOpen(true);
223
+ return;
224
+ }
225
+
226
+ if (e.key === "ArrowDown") {
227
+ e.preventDefault();
228
+ setActiveIndex((i) => Math.min(i + 1, filteredOptions.length - 1));
229
+ } else if (e.key === "ArrowUp") {
230
+ e.preventDefault();
231
+ setActiveIndex((i) => Math.max(i - 1, 0));
232
+ } else if (e.key === "Enter") {
233
+ e.preventDefault();
234
+ if (activeIndex >= 0 && filteredOptions[activeIndex]) {
235
+ commitSelection(filteredOptions[activeIndex]);
236
+ }
237
+ } else if (e.key === "Escape") {
238
+ setOpen(false);
239
+ setActiveIndex(-1);
240
+ }
241
+ },
242
+ [open, activeIndex, filteredOptions, commitSelection],
243
+ );
244
+
245
+ const listContent = (
246
+ <div
247
+ role="listbox"
248
+ style={{ boxShadow: "var(--dropdown-menu-shadow)", ...listboxStyle }}
249
+ className={cn(
250
+ "max-h-60 overflow-y-auto",
251
+ "rounded-md border border-bg-stroke3",
252
+ "bg-modal-dropdown-surface text-text-g-contrast-high",
253
+ !portal && "absolute top-full left-0 w-full -mt-3 z-[51]",
254
+ portal && "z-[51]",
255
+ listboxClassName,
256
+ )}
257
+ data-testid={testId ? `${testId}-listbox` : undefined}
258
+ >
259
+ {loading ? (
260
+ <div className="flex items-center justify-center py-6">
261
+ <Loading size={20} />
262
+ </div>
263
+ ) : filteredOptions.length === 0 ? (
264
+ <div className="px-4 py-6 text-center">
265
+ <Text
266
+ variant="small1"
267
+ className="text-[var(--dropdown-menu-default-text)]"
268
+ >
269
+ {noOptionsText}
270
+ </Text>
271
+ </div>
272
+ ) : (
273
+ filteredOptions.map((option, index) => {
274
+ const isSelected = option.value === value;
275
+ const isActive = index === activeIndex;
276
+
277
+ return (
278
+ <button
279
+ key={option.value}
280
+ id={`autocomplete-option-${option.value}`}
281
+ type="button"
282
+ role="option"
283
+ aria-selected={isSelected}
284
+ style={optionStyle}
285
+ className={cn(
286
+ menuItemBaseStyles,
287
+ "w-full",
288
+ isSelected &&
289
+ "bg-[var(--dropdown-menu-selected-bg)] text-[var(--dropdown-menu-selected-text)]",
290
+ isActive &&
291
+ !isSelected &&
292
+ "bg-[var(--dropdown-menu-hover-bg)] text-[var(--dropdown-menu-hover-text)]",
293
+ optionClassName,
294
+ )}
295
+ onMouseDown={(e) => e.preventDefault()}
296
+ onClick={() => commitSelection(option)}
297
+ data-testid={`autocomplete-option-${option.value}`}
298
+ >
299
+ <span className="shrink-0 size-4 flex items-center justify-center">
300
+ {isSelected && (
301
+ <Icon
302
+ type="heroicons"
303
+ name="check"
304
+ className="size-4 text-[var(--dropdown-menu-selected-text)]"
305
+ />
306
+ )}
307
+ </span>
308
+ {renderOption ? renderOption(option, isSelected) : option.label}
309
+ </button>
310
+ );
311
+ })
312
+ )}
313
+ </div>
314
+ );
315
+
316
+ return (
317
+ <PopoverPrimitive.Root open={showPopover}>
318
+ <PopoverPrimitive.Anchor asChild>
319
+ <div className={cn("relative", fullwidth && "w-full")}>
320
+ <TextInput
321
+ {...rest}
322
+ ref={ref ?? inputRef}
323
+ id={id}
324
+ label={label}
325
+ value={value}
326
+ size={size}
327
+ rounded={rounded}
328
+ variant={variant}
329
+ disabled={disabled}
330
+ fullwidth={fullwidth}
331
+ autoComplete="off"
332
+ role="combobox"
333
+ aria-expanded={showPopover}
334
+ aria-autocomplete="list"
335
+ aria-activedescendant={
336
+ activeIndex >= 0
337
+ ? `autocomplete-option-${filteredOptions[activeIndex]?.value}`
338
+ : undefined
339
+ }
340
+ hasClearIcon
341
+ onChange={handleInputChange}
342
+ onFocus={handleFocus}
343
+ onBlur={handleBlur}
344
+ onKeyDown={handleKeyDown}
345
+ data-testid={testId}
346
+ />
347
+ {showPopover && !portal && listContent}
348
+ </div>
349
+ </PopoverPrimitive.Anchor>
350
+
351
+ {portal && (
352
+ <PopoverPrimitive.Portal>
353
+ <PopoverPrimitive.Content
354
+ onOpenAutoFocus={(e) => e.preventDefault()}
355
+ onInteractOutside={(e) => e.preventDefault()}
356
+ onFocusOutside={(e) => e.preventDefault()}
357
+ side="bottom"
358
+ align="start"
359
+ sideOffset={-12}
360
+ style={{ width: "var(--radix-popover-trigger-width)", zIndex: 51, ...popoverStyle }}
361
+ >
362
+ {listContent}
363
+ </PopoverPrimitive.Content>
364
+ </PopoverPrimitive.Portal>
365
+ )}
366
+ </PopoverPrimitive.Root>
367
+ );
368
+ }
369
+
370
+ // forwardRef with generics requires a manual cast
371
+ const AutoComplete = forwardRef(AutoCompleteInner) as <
372
+ T extends AutoCompleteOption = AutoCompleteOption,
373
+ >(
374
+ props: AutoCompleteProps<T> & { ref?: React.ForwardedRef<HTMLInputElement> },
375
+ ) => ReturnType<typeof AutoCompleteInner>;
376
+
377
+ (AutoComplete as { displayName?: string }).displayName = "AutoComplete";
378
+
379
+ export default AutoComplete;
@@ -0,0 +1,2 @@
1
+ export { default } from "./AutoComplete";
2
+ export type { AutoCompleteProps, AutoCompleteOption } from "./AutoComplete";
@@ -55,6 +55,10 @@ const DialogContent = React.forwardRef<
55
55
  className,
56
56
  )}
57
57
  {...props}
58
+ onOpenAutoFocus={(event) => {
59
+ event.preventDefault();
60
+ props?.onOpenAutoFocus?.(event);
61
+ }}
58
62
  onCloseAutoFocus={(event) => {
59
63
  event.preventDefault();
60
64
  document.body.style.pointerEvents = "auto";
@@ -7,6 +7,7 @@ import React, {
7
7
  FocusEvent,
8
8
  KeyboardEvent,
9
9
  ChangeEvent,
10
+ MouseEvent,
10
11
  } from "react";
11
12
  import { useStableMergedRef } from "@/utils/mergeRefs";
12
13
  import {
@@ -231,16 +232,20 @@ export const TextInput = forwardRef<HTMLInputElement, InputProps>(
231
232
  ? format(props.value)
232
233
  : props.value;
233
234
 
234
- const handleClearInput = useCallback(() => {
235
- if (inputRef.current) {
236
- inputRef.current.value = "";
235
+ const handleClearInput = useCallback(
236
+ (e: MouseEvent<SVGSVGElement>) => {
237
+ e.preventDefault();
238
+ if (inputRef.current) {
239
+ inputRef.current.value = "";
237
240
 
238
- if (props.onChange) {
239
- const event = new Event("input", { bubbles: true });
240
- props.onChange({ target: { value: "" } } as any);
241
+ if (props.onChange) {
242
+ const event = new Event("input", { bubbles: true });
243
+ props.onChange({ target: { value: "" } } as any);
244
+ }
241
245
  }
242
- }
243
- }, [props.onChange]);
246
+ },
247
+ [props.onChange],
248
+ );
244
249
 
245
250
  const handleOnClickLeftSectionIcon = useCallback(() => {
246
251
  if (disabled) return;
package/src/index.ts CHANGED
@@ -12,6 +12,9 @@ export { default as TextArea } from "./components/TextArea/TextArea";
12
12
  export { default as Text } from "./components/Text/Text";
13
13
  export { default as Tabs } from "./components/Tabs/Tabs";
14
14
  export { default as Dropdown } from "./components/Dropdown/Dropdown";
15
+ export { menuItemBaseStyles } from "./components/Dropdown/Dropdown";
16
+ export { default as AutoComplete } from "./components/AutoComplete/AutoComplete";
17
+ export type { AutoCompleteProps, AutoCompleteOption } from "./components/AutoComplete/AutoComplete";
15
18
  export { Checkbox } from "./components/Checkbox/Checkbox";
16
19
  export { Label } from "./components/Label/Label";
17
20
  export { Input } from "./components/Input/Input";