@rovula/ui 0.1.41 → 0.1.42

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,525 @@
1
+ import React, { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import * as yup from "yup";
4
+ import AutoComplete, { type AutoCompleteOption } from "./AutoComplete";
5
+ import Avatar from "../Avatar/Avatar";
6
+ import Text from "../Text/Text";
7
+ import Button from "../Button/Button";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ DialogDescription,
14
+ DialogBody,
15
+ DialogFooter,
16
+ DialogTrigger,
17
+ DialogClose,
18
+ } from "../Dialog/Dialog";
19
+ import { Form, useControlledForm } from "../Form/Form";
20
+ import { Field } from "../Form/Field";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Meta
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const meta = {
27
+ title: "Components/AutoComplete",
28
+ component: AutoComplete,
29
+ tags: ["autodocs"],
30
+ parameters: {
31
+ layout: "fullscreen",
32
+ },
33
+ decorators: [
34
+ (Story) => (
35
+ <div className="p-8 bg-bg-bg1">
36
+ <Story />
37
+ </div>
38
+ ),
39
+ ],
40
+ } satisfies Meta<typeof AutoComplete>;
41
+
42
+ export default meta;
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Shared fixtures
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const fruits: AutoCompleteOption[] = [
49
+ { value: "apple", label: "Apple" },
50
+ { value: "banana", label: "Banana" },
51
+ { value: "blueberry", label: "Blueberry" },
52
+ { value: "cherry", label: "Cherry" },
53
+ { value: "grape", label: "Grape" },
54
+ { value: "mango", label: "Mango" },
55
+ { value: "orange", label: "Orange" },
56
+ { value: "peach", label: "Peach" },
57
+ { value: "pineapple", label: "Pineapple" },
58
+ { value: "strawberry", label: "Strawberry" },
59
+ ];
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Default — basic client-side filter
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export const Default: StoryObj<typeof AutoComplete> = {
66
+ render: () => {
67
+ const [value, setValue] = useState("");
68
+ const filtered = fruits.filter((f) =>
69
+ f.label.toLowerCase().includes(value.toLowerCase()),
70
+ );
71
+
72
+ return (
73
+ <AutoComplete
74
+ label="Fruit"
75
+ value={value}
76
+ options={filtered}
77
+ onChange={setValue}
78
+ onSearch={setValue}
79
+ noOptionsText="No fruit found"
80
+ fullwidth
81
+ data-testid="autocomplete-default"
82
+ />
83
+ );
84
+ },
85
+ };
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // showNoOptions — display "no results" message when empty (like MUI)
89
+ // ---------------------------------------------------------------------------
90
+
91
+ export const ShowNoOptionsMessage: StoryObj = {
92
+ name: "Show No Options Message",
93
+ render: () => {
94
+ const [value, setValue] = useState("");
95
+ const filtered = fruits.filter((f) =>
96
+ f.label.toLowerCase().includes(value.toLowerCase()),
97
+ );
98
+
99
+ return (
100
+ <div className="flex flex-col gap-6 w-full">
101
+ <div>
102
+ <Text variant="small4" className="text-text-g-contrast-medium mb-2">
103
+ showNoOptions=false (default) — popover hides when empty
104
+ </Text>
105
+ <AutoComplete
106
+ label="Fruit (no message)"
107
+ value={value}
108
+ options={filtered}
109
+ onChange={setValue}
110
+ onSearch={setValue}
111
+ showNoOptions={false}
112
+ noOptionsText="No fruit found"
113
+ fullwidth
114
+ />
115
+ </div>
116
+ <div>
117
+ <Text variant="small4" className="text-text-g-contrast-medium mb-2">
118
+ showNoOptions=true — shows "No fruit found" when no match
119
+ </Text>
120
+ <AutoComplete
121
+ label="Fruit (with message)"
122
+ value={value}
123
+ options={filtered}
124
+ onChange={setValue}
125
+ onSearch={setValue}
126
+ showNoOptions
127
+ noOptionsText="No fruit found"
128
+ fullwidth
129
+ />
130
+ </div>
131
+ </div>
132
+ );
133
+ },
134
+ };
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // FreeSolo — typed value is committed even without selecting from list
138
+ // ---------------------------------------------------------------------------
139
+
140
+ export const FreeSolo: StoryObj = {
141
+ name: "Free Solo (typed value always committed)",
142
+ render: () => {
143
+ const [value, setValue] = useState("");
144
+ const [committed, setCommitted] = useState("");
145
+
146
+ const filtered = fruits.filter((f) =>
147
+ f.label.toLowerCase().includes(value.toLowerCase()),
148
+ );
149
+
150
+ return (
151
+ <div className="flex flex-col gap-4 w-full">
152
+ <AutoComplete
153
+ label="Email or name"
154
+ value={value}
155
+ options={filtered}
156
+ onChange={(v) => {
157
+ setValue(v);
158
+ setCommitted(v);
159
+ }}
160
+ onSearch={setValue}
161
+ showNoOptions
162
+ noOptionsText="No match — your typed value will be used"
163
+ fullwidth
164
+ />
165
+ <Text variant="small2" className="text-text-g-contrast-medium">
166
+ Committed value: <strong>{committed || "—"}</strong>
167
+ </Text>
168
+ </div>
169
+ );
170
+ },
171
+ };
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Async — simulates server search with debounce
175
+ // ---------------------------------------------------------------------------
176
+
177
+ const allUsers = [
178
+ { value: "alice@co.com", label: "alice@co.com", name: "Alice" },
179
+ { value: "bob@co.com", label: "bob@co.com", name: "Bob" },
180
+ { value: "charlie@co.com", label: "charlie@co.com", name: "Charlie" },
181
+ { value: "diana@co.com", label: "diana@co.com", name: "Diana" },
182
+ ];
183
+
184
+ type UserOption = AutoCompleteOption & { name: string };
185
+
186
+ const AsyncAutoComplete = () => {
187
+ const [value, setValue] = useState("");
188
+ const [options, setOptions] = useState<UserOption[]>(allUsers);
189
+ const [loading, setLoading] = useState(false);
190
+
191
+ const handleSearch = (query: string) => {
192
+ setLoading(true);
193
+ setTimeout(() => {
194
+ setOptions(
195
+ query
196
+ ? allUsers.filter(
197
+ (u) =>
198
+ u.name.toLowerCase().includes(query.toLowerCase()) ||
199
+ u.value.toLowerCase().includes(query.toLowerCase()),
200
+ )
201
+ : allUsers,
202
+ );
203
+ setLoading(false);
204
+ }, 600);
205
+ };
206
+
207
+ return (
208
+ <AutoComplete<UserOption>
209
+ label="Search user"
210
+ value={value}
211
+ options={options}
212
+ loading={loading}
213
+ onChange={setValue}
214
+ onSearch={handleSearch}
215
+ onSelect={(option) => setValue(option.value)}
216
+ filterOptions={(x) => x}
217
+ showNoOptions
218
+ noOptionsText="No users found"
219
+ renderOption={(option) => (
220
+ <div className="flex items-center gap-3">
221
+ <Avatar
222
+ type="text"
223
+ text={option.name}
224
+ className="size-7 shrink-0 text-xs"
225
+ />
226
+ <div className="flex flex-col min-w-0">
227
+ <Text variant="subtitle4" className="truncate">
228
+ {option.name}
229
+ </Text>
230
+ <Text
231
+ variant="small2"
232
+ className="text-text-g-contrast-medium truncate"
233
+ >
234
+ {option.value}
235
+ </Text>
236
+ </div>
237
+ </div>
238
+ )}
239
+ fullwidth
240
+ data-testid="autocomplete-async"
241
+ />
242
+ );
243
+ };
244
+
245
+ export const Async: StoryObj = {
246
+ name: "Async Search (simulated API)",
247
+ render: () => <AsyncAutoComplete />,
248
+ };
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Form integration — react-hook-form + yup validation + Field
252
+ // ---------------------------------------------------------------------------
253
+
254
+ type InviteFormValues = {
255
+ email: string;
256
+ assignee: string;
257
+ };
258
+
259
+ const inviteSchema = yup.object({
260
+ email: yup
261
+ .string()
262
+ .email("Invalid email format")
263
+ .required("Email is required"),
264
+ assignee: yup.string().required("Please select an assignee"),
265
+ });
266
+
267
+ const FormIntegrationDemo = () => {
268
+ const [submitted, setSubmitted] = useState<InviteFormValues | null>(null);
269
+ const [assigneeQuery, setAssigneeQuery] = useState("");
270
+
271
+ const assigneeOptions = allUsers
272
+ .filter(
273
+ (u) =>
274
+ !assigneeQuery ||
275
+ u.name.toLowerCase().includes(assigneeQuery.toLowerCase()) ||
276
+ u.value.toLowerCase().includes(assigneeQuery.toLowerCase()),
277
+ )
278
+ .map((u) => ({ value: u.value, label: u.name }));
279
+
280
+ return (
281
+ <div className="flex flex-col gap-4 w-full max-w-sm">
282
+ <Form<InviteFormValues>
283
+ className="flex flex-col gap-4"
284
+ defaultValues={{ email: "", assignee: "" }}
285
+ validationSchema={inviteSchema}
286
+ mode="onBlur"
287
+ onSubmit={(values) => setSubmitted(values)}
288
+ >
289
+ <Field<InviteFormValues, "email">
290
+ name="email"
291
+ component={AutoComplete as React.ComponentType<any>}
292
+ componentProps={{
293
+ label: "Email",
294
+ placeholder: "Search by email",
295
+ options: allUsers
296
+ .filter((u) => !assigneeQuery || u.value.includes(assigneeQuery))
297
+ .map((u) => ({ value: u.value, label: u.value })),
298
+ onSearch: setAssigneeQuery,
299
+ showNoOptions: true,
300
+ noOptionsText: "No matching email",
301
+ required: true,
302
+ fullwidth: true,
303
+ }}
304
+ />
305
+ <Field<InviteFormValues, "assignee">
306
+ name="assignee"
307
+ component={AutoComplete as React.ComponentType<any>}
308
+ componentProps={{
309
+ label: "Assignee",
310
+ placeholder: "Search by name",
311
+ options: assigneeOptions,
312
+ onSearch: setAssigneeQuery,
313
+ showNoOptions: true,
314
+ noOptionsText: "No users found",
315
+ required: true,
316
+ fullwidth: true,
317
+ }}
318
+ />
319
+ <Button type="submit" fullwidth>
320
+ Submit
321
+ </Button>
322
+ </Form>
323
+ {submitted && (
324
+ <div className="mt-2 p-3 rounded-md bg-bg-bg2 typography-small2 text-text-g-contrast-medium">
325
+ Submitted: <strong>{JSON.stringify(submitted)}</strong>
326
+ </div>
327
+ )}
328
+ </div>
329
+ );
330
+ };
331
+
332
+ export const WithFormValidation: StoryObj = {
333
+ name: "Form — react-hook-form + yup + Field",
334
+ render: () => <FormIntegrationDemo />,
335
+ };
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Inside Dialog — portal + overflow escape
339
+ // ---------------------------------------------------------------------------
340
+
341
+ const dialogAssignees = allUsers.map((u) => ({
342
+ value: u.value,
343
+ label: u.name,
344
+ }));
345
+
346
+ const AutoCompleteInDialogDemo = () => {
347
+ const [query, setQuery] = useState("");
348
+ const [value, setValue] = useState("");
349
+
350
+ const options = dialogAssignees.filter(
351
+ (o) =>
352
+ !query ||
353
+ o.label.toLowerCase().includes(query.toLowerCase()) ||
354
+ o.value.toLowerCase().includes(query.toLowerCase()),
355
+ );
356
+
357
+ return (
358
+ <Dialog>
359
+ <DialogTrigger asChild>
360
+ <Button variant="outline">Open Dialog</Button>
361
+ </DialogTrigger>
362
+ <DialogContent showCloseButton>
363
+ <DialogHeader>
364
+ <DialogTitle>Assign Task</DialogTitle>
365
+ <DialogDescription>
366
+ AutoComplete inside a Dialog — popover escapes overflow correctly.
367
+ </DialogDescription>
368
+ </DialogHeader>
369
+ <DialogBody className="gap-4 py-2">
370
+ <AutoComplete
371
+ label="Assignee"
372
+ placeholder="Search by name or email"
373
+ value={value}
374
+ options={options}
375
+ onChange={setValue}
376
+ onSearch={setQuery}
377
+ onSelect={(opt) => setValue(opt.value)}
378
+ filterOptions={(x) => x}
379
+ showNoOptions
380
+ noOptionsText="No users found"
381
+ portal={false}
382
+ required
383
+ fullwidth
384
+ />
385
+ </DialogBody>
386
+ <DialogFooter>
387
+ <DialogClose asChild>
388
+ <Button variant="outline">Cancel</Button>
389
+ </DialogClose>
390
+ <Button disabled={!value}>Confirm</Button>
391
+ </DialogFooter>
392
+ </DialogContent>
393
+ </Dialog>
394
+ );
395
+ };
396
+
397
+ export const InsideDialog: StoryObj = {
398
+ name: "Inside Dialog",
399
+ render: () => <AutoCompleteInDialogDemo />,
400
+ };
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Sizes
404
+ // ---------------------------------------------------------------------------
405
+
406
+ export const Sizes: StoryObj = {
407
+ render: () => {
408
+ const [sm, setSm] = useState("");
409
+ const [md, setMd] = useState("");
410
+ const [lg, setLg] = useState("");
411
+
412
+ const getOptions = (v: string) =>
413
+ fruits.filter((f) => f.label.toLowerCase().includes(v.toLowerCase()));
414
+
415
+ return (
416
+ <div className="flex flex-col gap-6 w-full">
417
+ {(
418
+ [
419
+ { size: "sm", value: sm, onChange: setSm },
420
+ { size: "md", value: md, onChange: setMd },
421
+ { size: "lg", value: lg, onChange: setLg },
422
+ ] as const
423
+ ).map(({ size, value, onChange }) => (
424
+ <AutoComplete
425
+ key={size}
426
+ label={`Size: ${size}`}
427
+ size={size}
428
+ value={value}
429
+ options={getOptions(value)}
430
+ onChange={onChange}
431
+ onSearch={onChange}
432
+ noOptionsText="No fruit found"
433
+ fullwidth
434
+ />
435
+ ))}
436
+ </div>
437
+ );
438
+ },
439
+ };
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // States — error, disabled
443
+ // ---------------------------------------------------------------------------
444
+
445
+ export const States: StoryObj = {
446
+ render: () => (
447
+ <div className="flex flex-col gap-6 w-full">
448
+ <AutoComplete
449
+ label="Error state"
450
+ value=""
451
+ options={fruits}
452
+ error
453
+ errorMessage="Please enter a valid email"
454
+ fullwidth
455
+ />
456
+ <AutoComplete
457
+ label="Disabled state"
458
+ value="locked@example.com"
459
+ options={fruits}
460
+ disabled
461
+ fullwidth
462
+ />
463
+ </div>
464
+ ),
465
+ };
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Loading state
469
+ // ---------------------------------------------------------------------------
470
+
471
+ export const LoadingState: StoryObj = {
472
+ name: "Loading State",
473
+ render: () => {
474
+ const [value, setValue] = useState("j");
475
+ return (
476
+ <AutoComplete
477
+ label="Searching…"
478
+ value={value}
479
+ options={[]}
480
+ loading
481
+ onChange={setValue}
482
+ noOptionsText="No results"
483
+ fullwidth
484
+ />
485
+ );
486
+ },
487
+ };
488
+
489
+ // ---------------------------------------------------------------------------
490
+ // Keyboard navigation
491
+ // ---------------------------------------------------------------------------
492
+
493
+ export const KeyboardNavigation: StoryObj = {
494
+ name: "Keyboard Navigation (ArrowUp/Down, Enter, Escape)",
495
+ render: () => {
496
+ const [value, setValue] = useState("");
497
+ const [selected, setSelected] = useState<AutoCompleteOption | null>(null);
498
+ const filtered = fruits.filter((f) =>
499
+ f.label.toLowerCase().includes(value.toLowerCase()),
500
+ );
501
+
502
+ return (
503
+ <div className="flex flex-col gap-4 w-full">
504
+ <AutoComplete
505
+ label="Pick a fruit (keyboard)"
506
+ value={value}
507
+ options={filtered}
508
+ onChange={setValue}
509
+ onSearch={setValue}
510
+ onSelect={(opt) => {
511
+ setValue(opt.label);
512
+ setSelected(opt);
513
+ }}
514
+ noOptionsText="No fruit found"
515
+ fullwidth
516
+ />
517
+ {selected && (
518
+ <Text variant="small2" className="text-text-g-contrast-medium">
519
+ Selected: <strong>{selected.label}</strong>
520
+ </Text>
521
+ )}
522
+ </div>
523
+ );
524
+ },
525
+ };