@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.
- package/dist/cjs/bundle.css +3 -0
- package/dist/cjs/bundle.js +4 -4
- package/dist/cjs/bundle.js.map +1 -1
- package/dist/cjs/types/components/AutoComplete/AutoComplete.d.ts +76 -0
- package/dist/cjs/types/components/AutoComplete/AutoComplete.stories.d.ts +362 -0
- package/dist/cjs/types/components/AutoComplete/index.d.ts +2 -0
- package/dist/cjs/types/index.d.ts +3 -0
- package/dist/components/AutoComplete/AutoComplete.js +103 -0
- package/dist/components/AutoComplete/AutoComplete.stories.js +212 -0
- package/dist/components/AutoComplete/index.js +1 -0
- package/dist/components/Dialog/Dialog.js +5 -1
- package/dist/components/TextInput/TextInput.js +2 -1
- package/dist/esm/bundle.css +3 -0
- package/dist/esm/bundle.js +4 -4
- package/dist/esm/bundle.js.map +1 -1
- package/dist/esm/types/components/AutoComplete/AutoComplete.d.ts +76 -0
- package/dist/esm/types/components/AutoComplete/AutoComplete.stories.d.ts +362 -0
- package/dist/esm/types/components/AutoComplete/index.d.ts +2 -0
- package/dist/esm/types/index.d.ts +3 -0
- package/dist/index.d.ts +77 -2
- package/dist/index.js +2 -0
- package/dist/src/theme/global.css +4 -0
- package/package.json +1 -1
- package/src/components/AutoComplete/AutoComplete.stories.tsx +525 -0
- package/src/components/AutoComplete/AutoComplete.tsx +379 -0
- package/src/components/AutoComplete/index.ts +2 -0
- package/src/components/Dialog/Dialog.tsx +4 -0
- package/src/components/TextInput/TextInput.tsx +13 -8
- package/src/index.ts +3 -0
|
@@ -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;
|
|
@@ -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
|
-
|
|
236
|
-
|
|
235
|
+
const handleClearInput = useCallback(
|
|
236
|
+
(e: MouseEvent<SVGSVGElement>) => {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
if (inputRef.current) {
|
|
239
|
+
inputRef.current.value = "";
|
|
237
240
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
if (props.onChange) {
|
|
242
|
+
const event = new Event("input", { bubbles: true });
|
|
243
|
+
props.onChange({ target: { value: "" } } as any);
|
|
244
|
+
}
|
|
241
245
|
}
|
|
242
|
-
}
|
|
243
|
-
|
|
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";
|