@bearmenu/ui 0.5.2 → 0.6.1
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/{chunk-ERMIA7NP.js → chunk-7PPCH42H.js} +3 -3
- package/dist/{chunk-HLQ2IRE7.js → chunk-BAZFVSSG.js} +3 -3
- package/dist/chunk-D5QHEHXJ.js +26 -0
- package/dist/{chunk-UISA4CIG.js → chunk-N2KNBPAQ.js} +2 -2
- package/dist/{chunk-7H54PTVH.js → chunk-PSR7N36A.js} +38 -10
- package/dist/chunk-RIDXFY6M.js +38 -0
- package/dist/chunk-UUJVXN2O.js +82 -0
- package/dist/chunk-WKMLZDUT.js +122 -0
- package/dist/chunk-XK2ZDQVE.js +122 -0
- package/dist/{chunk-4KOMHDZD.js → chunk-Y5D5LP4A.js} +18 -3
- package/dist/chunk-YJ6OBKZJ.js +32 -0
- package/dist/{chunk-ESO2EM3L.js → chunk-ZBBUKXM5.js} +2 -2
- package/dist/chunk-ZFNOCP6W.js +106 -0
- package/dist/components/accordion.js +11 -5
- package/dist/components/alert-dialog.js +5 -3
- package/dist/components/alert.d.ts +4 -1
- package/dist/components/alert.js +25 -9
- package/dist/components/avatar.d.ts +17 -2
- package/dist/components/avatar.js +91 -12
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/breadcrumb.js +5 -2
- package/dist/components/button-group.d.ts +14 -0
- package/dist/components/button-group.js +39 -0
- package/dist/components/button.d.ts +3 -1
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +3 -3
- package/dist/components/card.d.ts +13 -5
- package/dist/components/card.js +2 -2
- package/dist/components/carousel.d.ts +20 -3
- package/dist/components/carousel.js +72 -12
- package/dist/components/checkbox.js +1 -1
- package/dist/components/circular-progress.d.ts +16 -0
- package/dist/components/circular-progress.js +105 -0
- package/dist/components/combobox.d.ts +27 -0
- package/dist/components/combobox.js +102 -0
- package/dist/components/command.d.ts +16 -3
- package/dist/components/command.js +2 -2
- package/dist/components/currency-input.d.ts +1 -0
- package/dist/components/currency-input.js +73 -52
- package/dist/components/date-input.d.ts +10 -0
- package/dist/components/date-input.js +40 -0
- package/dist/components/description-list.d.ts +15 -0
- package/dist/components/description-list.js +67 -0
- package/dist/components/dialog.d.ts +13 -2
- package/dist/components/dialog.js +1 -1
- package/dist/components/drawer.d.ts +5 -2
- package/dist/components/drawer.js +47 -9
- package/dist/components/dropdown-menu.d.ts +7 -2
- package/dist/components/dropdown-menu.js +22 -6
- package/dist/components/empty-state.js +2 -2
- package/dist/components/field.d.ts +22 -0
- package/dist/components/field.js +130 -0
- package/dist/components/form.d.ts +2 -1
- package/dist/components/form.js +11 -1
- package/dist/components/haptic-button.js +3 -3
- package/dist/components/input-group.d.ts +18 -0
- package/dist/components/input-group.js +92 -0
- package/dist/components/input.d.ts +8 -2
- package/dist/components/input.js +1 -1
- package/dist/components/item.d.ts +23 -0
- package/dist/components/item.js +120 -0
- package/dist/components/kbd.d.ts +13 -0
- package/dist/components/kbd.js +49 -0
- package/dist/components/lazy-markdown.d.ts +3 -1
- package/dist/components/lazy-markdown.js +7 -7
- package/dist/components/link-button.js +1 -1
- package/dist/components/multi-select-combobox.js +92 -83
- package/dist/components/multi-select-menu.d.ts +0 -4
- package/dist/components/multi-select-menu.js +28 -17
- package/dist/components/native-select.d.ts +12 -0
- package/dist/components/native-select.js +49 -0
- package/dist/components/pagination.d.ts +5 -2
- package/dist/components/pagination.js +66 -7
- package/dist/components/place-about-tab.js +4 -4
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +4 -4
- package/dist/components/popover.d.ts +7 -1
- package/dist/components/popover.js +1 -1
- package/dist/components/progress.d.ts +5 -1
- package/dist/components/progress.js +17 -3
- package/dist/components/radio-group.js +12 -3
- package/dist/components/rating.d.ts +2 -0
- package/dist/components/rating.js +88 -70
- package/dist/components/scroll-fade.d.ts +15 -0
- package/dist/components/scroll-fade.js +116 -0
- package/dist/components/search-bar.d.ts +1 -2
- package/dist/components/search-bar.js +37 -60
- package/dist/components/searchable-select.js +76 -72
- package/dist/components/select.d.ts +5 -1
- package/dist/components/select.js +30 -14
- package/dist/components/selection-bar.d.ts +11 -0
- package/dist/components/selection-bar.js +65 -0
- package/dist/components/separator.js +3 -2
- package/dist/components/sheet.d.ts +1 -1
- package/dist/components/sheet.js +1 -1
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.js +1 -1
- package/dist/components/slider.js +21 -4
- package/dist/components/sliding-panels.js +9 -6
- package/dist/components/spinner.d.ts +17 -0
- package/dist/components/spinner.js +50 -0
- package/dist/components/stepper.d.ts +18 -0
- package/dist/components/stepper.js +199 -0
- package/dist/components/switch.js +4 -3
- package/dist/components/table.d.ts +23 -5
- package/dist/components/table.js +121 -35
- package/dist/components/tabs.d.ts +3 -1
- package/dist/components/tabs.js +37 -9
- package/dist/components/tag-group.d.ts +9 -0
- package/dist/components/tag-group.js +26 -0
- package/dist/components/tag.d.ts +17 -0
- package/dist/components/tag.js +61 -0
- package/dist/components/text.d.ts +1 -1
- package/dist/components/text.js +1 -1
- package/dist/components/textarea.js +2 -1
- package/dist/components/toast.js +1 -1
- package/dist/components/toaster.js +1 -1
- package/dist/components/toggle-group.js +1 -1
- package/dist/components/toggle.js +1 -1
- package/dist/components/tooltip.d.ts +3 -2
- package/dist/components/tooltip.js +16 -2
- package/package.json +45 -26
- package/src/components/accordion.tsx +11 -6
- package/src/components/alert-dialog.tsx +4 -2
- package/src/components/alert.tsx +20 -8
- package/src/components/avatar.tsx +106 -13
- package/src/components/badge.tsx +12 -11
- package/src/components/breadcrumb.tsx +5 -2
- package/src/components/button-group.stories.tsx +52 -0
- package/src/components/button-group.tsx +40 -0
- package/src/components/button.test.tsx +174 -0
- package/src/components/button.tsx +42 -19
- package/src/components/calendar.tsx +2 -2
- package/src/components/card.tsx +55 -13
- package/src/components/carousel.tsx +85 -13
- package/src/components/checkbox.test.tsx +58 -0
- package/src/components/checkbox.tsx +8 -3
- package/src/components/circular-progress.stories.tsx +39 -0
- package/src/components/circular-progress.tsx +102 -0
- package/src/components/collapsible.stories.tsx +7 -3
- package/src/components/combobox.stories.tsx +82 -0
- package/src/components/combobox.test.tsx +118 -0
- package/src/components/combobox.tsx +136 -0
- package/src/components/command.test.tsx +91 -0
- package/src/components/command.tsx +38 -15
- package/src/components/currency-input.test.tsx +63 -0
- package/src/components/currency-input.tsx +84 -66
- package/src/components/date-input.stories.tsx +31 -0
- package/src/components/date-input.tsx +43 -0
- package/src/components/description-list.stories.tsx +62 -0
- package/src/components/description-list.tsx +68 -0
- package/src/components/dialog.test.tsx +84 -0
- package/src/components/dialog.tsx +64 -24
- package/src/components/drawer.tsx +72 -23
- package/src/components/dropdown-menu.test.tsx +180 -0
- package/src/components/dropdown-menu.tsx +31 -6
- package/src/components/field.stories.tsx +36 -0
- package/src/components/field.test.tsx +157 -0
- package/src/components/field.tsx +146 -0
- package/src/components/form.stories.tsx +3 -2
- package/src/components/form.test.tsx +95 -0
- package/src/components/form.tsx +9 -0
- package/src/components/haptic-button.tsx +1 -1
- package/src/components/input-group.stories.tsx +61 -0
- package/src/components/input-group.tsx +99 -0
- package/src/components/input.test.tsx +103 -0
- package/src/components/input.tsx +24 -7
- package/src/components/item.stories.tsx +52 -0
- package/src/components/item.tsx +115 -0
- package/src/components/kbd.stories.tsx +33 -0
- package/src/components/kbd.tsx +46 -0
- package/src/components/lazy-markdown.tsx +10 -7
- package/src/components/multi-select-combobox.test.tsx +101 -0
- package/src/components/multi-select-combobox.tsx +85 -82
- package/src/components/multi-select-menu.tsx +49 -41
- package/src/components/native-select.stories.tsx +52 -0
- package/src/components/native-select.tsx +45 -0
- package/src/components/pagination.tsx +64 -5
- package/src/components/popover.tsx +66 -3
- package/src/components/progress.tsx +27 -4
- package/src/components/radio-group.test.tsx +64 -0
- package/src/components/radio-group.tsx +11 -3
- package/src/components/rating.test.tsx +112 -0
- package/src/components/rating.tsx +97 -82
- package/src/components/scroll-fade.stories.tsx +98 -0
- package/src/components/scroll-fade.tsx +127 -0
- package/src/components/search-bar.stories.tsx +1 -32
- package/src/components/search-bar.tsx +22 -59
- package/src/components/searchable-select.tsx +69 -73
- package/src/components/select.tsx +34 -19
- package/src/components/selection-bar.stories.tsx +37 -0
- package/src/components/selection-bar.tsx +65 -0
- package/src/components/separator.tsx +5 -2
- package/src/components/sheet.tsx +1 -1
- package/src/components/skeleton.tsx +2 -2
- package/src/components/slider.test.tsx +45 -0
- package/src/components/slider.tsx +31 -12
- package/src/components/sliding-panels.tsx +8 -5
- package/src/components/spinner.stories.tsx +35 -0
- package/src/components/spinner.tsx +50 -0
- package/src/components/stepper.stories.tsx +71 -0
- package/src/components/stepper.tsx +220 -0
- package/src/components/switch.test.tsx +52 -0
- package/src/components/switch.tsx +4 -3
- package/src/components/table.tsx +140 -35
- package/src/components/tabs.test.tsx +107 -0
- package/src/components/tabs.tsx +70 -23
- package/src/components/tag-group.stories.tsx +39 -0
- package/src/components/tag-group.tsx +28 -0
- package/src/components/tag.stories.tsx +50 -0
- package/src/components/tag.tsx +63 -0
- package/src/components/text.test.tsx +1 -1
- package/src/components/text.tsx +19 -3
- package/src/components/textarea.test.tsx +48 -0
- package/src/components/textarea.tsx +2 -1
- package/src/components/toast.tsx +4 -3
- package/src/components/toggle.tsx +2 -2
- package/src/components/tooltip.tsx +12 -2
- package/src/globals.css +126 -90
- package/dist/chunk-2BMJRXQX.js +0 -80
- package/dist/chunk-4AVS6CV4.js +0 -89
- package/dist/chunk-4UO73SKC.js +0 -26
- package/dist/chunk-CW24PLDW.js +0 -24
- package/dist/chunk-IQASCH2V.js +0 -25
- package/dist/chunk-NKHH23YK.js +0 -31
- package/dist/chunk-QJ54Z7TC.js +0 -83
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Label } from "./label";
|
|
7
|
+
|
|
8
|
+
const FieldContext = React.createContext<{
|
|
9
|
+
id: string;
|
|
10
|
+
descriptionId: string;
|
|
11
|
+
errorId: string;
|
|
12
|
+
hasError: boolean;
|
|
13
|
+
}>({ id: "", descriptionId: "", errorId: "", hasError: false });
|
|
14
|
+
|
|
15
|
+
type FieldProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
16
|
+
/** Field id. Auto-generated if omitted. */
|
|
17
|
+
id?: string;
|
|
18
|
+
/** Marks the field invalid. */
|
|
19
|
+
invalid?: boolean;
|
|
20
|
+
/** Lay out content horizontally instead of stacked. */
|
|
21
|
+
orientation?: "vertical" | "horizontal";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
|
25
|
+
({ id: providedId, invalid, orientation = "vertical", className, children, ...props }, ref) => {
|
|
26
|
+
const generatedId = React.useId();
|
|
27
|
+
const id = providedId ?? generatedId;
|
|
28
|
+
const descriptionId = `${id}-description`;
|
|
29
|
+
const errorId = `${id}-error`;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<FieldContext.Provider value={{ id, descriptionId, errorId, hasError: !!invalid }}>
|
|
33
|
+
<div
|
|
34
|
+
ref={ref}
|
|
35
|
+
data-slot="field"
|
|
36
|
+
data-invalid={invalid || undefined}
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex gap-2",
|
|
39
|
+
orientation === "vertical" ? "flex-col" : "flex-row items-center",
|
|
40
|
+
className
|
|
41
|
+
)}
|
|
42
|
+
{...props}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</div>
|
|
46
|
+
</FieldContext.Provider>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
Field.displayName = "Field";
|
|
51
|
+
|
|
52
|
+
const FieldLabel = React.forwardRef<
|
|
53
|
+
HTMLLabelElement,
|
|
54
|
+
React.ComponentPropsWithoutRef<typeof Label>
|
|
55
|
+
>(({ className, ...props }, ref) => {
|
|
56
|
+
const { id, hasError } = React.useContext(FieldContext);
|
|
57
|
+
return (
|
|
58
|
+
<Label
|
|
59
|
+
ref={ref}
|
|
60
|
+
data-slot="field-label"
|
|
61
|
+
htmlFor={id}
|
|
62
|
+
className={cn(hasError && "text-destructive", className)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
FieldLabel.displayName = "FieldLabel";
|
|
68
|
+
|
|
69
|
+
const FieldControl = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<typeof Slot>>(
|
|
70
|
+
({ ...props }, ref) => {
|
|
71
|
+
const { id, descriptionId, errorId, hasError } = React.useContext(FieldContext);
|
|
72
|
+
return (
|
|
73
|
+
<Slot
|
|
74
|
+
ref={ref}
|
|
75
|
+
data-slot="field-control"
|
|
76
|
+
id={id}
|
|
77
|
+
aria-describedby={hasError ? `${descriptionId} ${errorId}` : descriptionId}
|
|
78
|
+
aria-invalid={hasError || undefined}
|
|
79
|
+
{...props}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
FieldControl.displayName = "FieldControl";
|
|
85
|
+
|
|
86
|
+
const FieldDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
87
|
+
({ className, ...props }, ref) => {
|
|
88
|
+
const { descriptionId } = React.useContext(FieldContext);
|
|
89
|
+
return (
|
|
90
|
+
<p
|
|
91
|
+
ref={ref}
|
|
92
|
+
id={descriptionId}
|
|
93
|
+
data-slot="field-description"
|
|
94
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
95
|
+
{...props}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
FieldDescription.displayName = "FieldDescription";
|
|
101
|
+
|
|
102
|
+
const FieldError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
103
|
+
({ className, children, ...props }, ref) => {
|
|
104
|
+
const { errorId, hasError } = React.useContext(FieldContext);
|
|
105
|
+
if (!hasError || !children) return null;
|
|
106
|
+
return (
|
|
107
|
+
<p
|
|
108
|
+
ref={ref}
|
|
109
|
+
id={errorId}
|
|
110
|
+
role="alert"
|
|
111
|
+
data-slot="field-error"
|
|
112
|
+
className={cn("text-sm text-destructive", className)}
|
|
113
|
+
{...props}
|
|
114
|
+
>
|
|
115
|
+
{children}
|
|
116
|
+
</p>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
FieldError.displayName = "FieldError";
|
|
121
|
+
|
|
122
|
+
const FieldSet = React.forwardRef<HTMLFieldSetElement, React.FieldsetHTMLAttributes<HTMLFieldSetElement>>(
|
|
123
|
+
({ className, ...props }, ref) => (
|
|
124
|
+
<fieldset
|
|
125
|
+
ref={ref}
|
|
126
|
+
data-slot="field-set"
|
|
127
|
+
className={cn("flex flex-col gap-3 rounded-xl border border-border p-4", className)}
|
|
128
|
+
{...props}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
FieldSet.displayName = "FieldSet";
|
|
133
|
+
|
|
134
|
+
const FieldLegend = React.forwardRef<HTMLLegendElement, React.HTMLAttributes<HTMLLegendElement>>(
|
|
135
|
+
({ className, ...props }, ref) => (
|
|
136
|
+
<legend
|
|
137
|
+
ref={ref}
|
|
138
|
+
data-slot="field-legend"
|
|
139
|
+
className={cn("px-1 text-sm font-medium text-foreground", className)}
|
|
140
|
+
{...props}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
FieldLegend.displayName = "FieldLegend";
|
|
145
|
+
|
|
146
|
+
export { Field, FieldLabel, FieldControl, FieldDescription, FieldError, FieldSet, FieldLegend };
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
FormItem,
|
|
9
9
|
FormLabel,
|
|
10
10
|
FormMessage,
|
|
11
|
+
FormRoot,
|
|
11
12
|
} from "./form";
|
|
12
13
|
import { Input } from "./input";
|
|
13
14
|
import { Button } from "./button";
|
|
@@ -31,7 +32,7 @@ function FormDemo() {
|
|
|
31
32
|
|
|
32
33
|
return (
|
|
33
34
|
<Form {...form}>
|
|
34
|
-
<
|
|
35
|
+
<FormRoot onSubmit={form.handleSubmit(() => {})} className="space-y-6 w-[400px]">
|
|
35
36
|
<FormField
|
|
36
37
|
control={form.control}
|
|
37
38
|
name="username"
|
|
@@ -63,7 +64,7 @@ function FormDemo() {
|
|
|
63
64
|
)}
|
|
64
65
|
/>
|
|
65
66
|
<Button type="submit">Submit</Button>
|
|
66
|
-
</
|
|
67
|
+
</FormRoot>
|
|
67
68
|
</Form>
|
|
68
69
|
);
|
|
69
70
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { useForm } from "react-hook-form";
|
|
5
|
+
import {
|
|
6
|
+
Form,
|
|
7
|
+
FormRoot,
|
|
8
|
+
FormField,
|
|
9
|
+
FormItem,
|
|
10
|
+
FormLabel,
|
|
11
|
+
FormControl,
|
|
12
|
+
FormDescription,
|
|
13
|
+
FormMessage,
|
|
14
|
+
} from "./form";
|
|
15
|
+
import { Input } from "./input";
|
|
16
|
+
|
|
17
|
+
function Wrapper({
|
|
18
|
+
onSubmit,
|
|
19
|
+
defaultValues,
|
|
20
|
+
}: {
|
|
21
|
+
onSubmit?: (data: { name: string }) => void;
|
|
22
|
+
defaultValues?: { name: string };
|
|
23
|
+
}) {
|
|
24
|
+
const form = useForm<{ name: string }>({
|
|
25
|
+
defaultValues: defaultValues ?? { name: "" },
|
|
26
|
+
});
|
|
27
|
+
return (
|
|
28
|
+
<Form {...form}>
|
|
29
|
+
<FormRoot onSubmit={form.handleSubmit((data) => onSubmit?.(data))}>
|
|
30
|
+
<FormField
|
|
31
|
+
control={form.control}
|
|
32
|
+
name="name"
|
|
33
|
+
rules={{ required: "Name is required" }}
|
|
34
|
+
render={({ field }) => (
|
|
35
|
+
<FormItem>
|
|
36
|
+
<FormLabel>Name</FormLabel>
|
|
37
|
+
<FormControl>
|
|
38
|
+
<Input {...field} />
|
|
39
|
+
</FormControl>
|
|
40
|
+
<FormDescription>Pick a name</FormDescription>
|
|
41
|
+
<FormMessage />
|
|
42
|
+
</FormItem>
|
|
43
|
+
)}
|
|
44
|
+
/>
|
|
45
|
+
<button type="submit">submit</button>
|
|
46
|
+
</FormRoot>
|
|
47
|
+
</Form>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("Form", () => {
|
|
52
|
+
it("FormRoot renders a form element with noValidate by default", () => {
|
|
53
|
+
const { container } = render(<Wrapper />);
|
|
54
|
+
const form = container.querySelector("form");
|
|
55
|
+
expect(form).not.toBeNull();
|
|
56
|
+
expect(form?.noValidate).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("FormLabel links to the input via htmlFor / id", () => {
|
|
60
|
+
render(<Wrapper />);
|
|
61
|
+
const label = screen.getByText("Name");
|
|
62
|
+
const input = screen.getByRole("textbox");
|
|
63
|
+
expect(label.getAttribute("for")).toBe(input.id);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("FormDescription is wired through aria-describedby", () => {
|
|
67
|
+
render(<Wrapper />);
|
|
68
|
+
const input = screen.getByRole("textbox");
|
|
69
|
+
const description = screen.getByText("Pick a name");
|
|
70
|
+
expect(input.getAttribute("aria-describedby") ?? "").toContain(description.id);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("submits valid values via onSubmit", async () => {
|
|
74
|
+
const user = userEvent.setup();
|
|
75
|
+
const onSubmit = vi.fn();
|
|
76
|
+
render(<Wrapper onSubmit={onSubmit} defaultValues={{ name: "ada" }} />);
|
|
77
|
+
await user.click(screen.getByRole("button", { name: /submit/i }));
|
|
78
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: "ada" });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("renders an error message and sets aria-invalid when validation fails", async () => {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
const onSubmit = vi.fn();
|
|
84
|
+
render(<Wrapper onSubmit={onSubmit} />);
|
|
85
|
+
await user.click(screen.getByRole("button", { name: /submit/i }));
|
|
86
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
87
|
+
expect(await screen.findByText("Name is required")).toBeDefined();
|
|
88
|
+
expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("FormRoot allows overriding noValidate to false", () => {
|
|
92
|
+
const { container } = render(<FormRoot noValidate={false} />);
|
|
93
|
+
expect(container.querySelector("form")?.noValidate).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/components/form.tsx
CHANGED
|
@@ -18,6 +18,14 @@ import { Label } from "./label";
|
|
|
18
18
|
|
|
19
19
|
const Form = FormProvider;
|
|
20
20
|
|
|
21
|
+
function FormRoot({
|
|
22
|
+
noValidate = true,
|
|
23
|
+
className,
|
|
24
|
+
...props
|
|
25
|
+
}: React.FormHTMLAttributes<HTMLFormElement>) {
|
|
26
|
+
return <form data-slot="form-root" noValidate={noValidate} className={className} {...props} />;
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
type FormFieldContextValue<
|
|
22
30
|
TFieldValues extends FieldValues = FieldValues,
|
|
23
31
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
|
@@ -143,6 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
|
|
143
151
|
export {
|
|
144
152
|
useFormField,
|
|
145
153
|
Form,
|
|
154
|
+
FormRoot,
|
|
146
155
|
FormItem,
|
|
147
156
|
FormLabel,
|
|
148
157
|
FormControl,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Mail, Search } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
InputGroup,
|
|
5
|
+
InputGroupAddon,
|
|
6
|
+
InputGroupButton,
|
|
7
|
+
InputGroupInput,
|
|
8
|
+
} from "./input-group";
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
title: "Components/InputGroup",
|
|
12
|
+
component: InputGroup,
|
|
13
|
+
tags: ["autodocs"],
|
|
14
|
+
} satisfies Meta<typeof InputGroup>;
|
|
15
|
+
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj<typeof meta>;
|
|
18
|
+
|
|
19
|
+
export const WithLeadingIcon: Story = {
|
|
20
|
+
render: () => (
|
|
21
|
+
<InputGroup className="w-72">
|
|
22
|
+
<InputGroupAddon>
|
|
23
|
+
<Search className="size-4" />
|
|
24
|
+
</InputGroupAddon>
|
|
25
|
+
<InputGroupInput placeholder="Search…" />
|
|
26
|
+
</InputGroup>
|
|
27
|
+
),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const WithTrailingButton: Story = {
|
|
31
|
+
render: () => (
|
|
32
|
+
<InputGroup className="w-72">
|
|
33
|
+
<InputGroupAddon>
|
|
34
|
+
<Mail className="size-4" />
|
|
35
|
+
</InputGroupAddon>
|
|
36
|
+
<InputGroupInput type="email" placeholder="you@example.com" />
|
|
37
|
+
<InputGroupAddon align="end">
|
|
38
|
+
<InputGroupButton>Send</InputGroupButton>
|
|
39
|
+
</InputGroupAddon>
|
|
40
|
+
</InputGroup>
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const Sizes: Story = {
|
|
45
|
+
render: () => (
|
|
46
|
+
<div className="flex flex-col gap-3 w-72">
|
|
47
|
+
<InputGroup size="sm">
|
|
48
|
+
<InputGroupAddon><Search className="size-4" /></InputGroupAddon>
|
|
49
|
+
<InputGroupInput placeholder="Small" />
|
|
50
|
+
</InputGroup>
|
|
51
|
+
<InputGroup size="default">
|
|
52
|
+
<InputGroupAddon><Search className="size-4" /></InputGroupAddon>
|
|
53
|
+
<InputGroupInput placeholder="Default" />
|
|
54
|
+
</InputGroup>
|
|
55
|
+
<InputGroup size="lg">
|
|
56
|
+
<InputGroupAddon><Search className="size-4" /></InputGroupAddon>
|
|
57
|
+
<InputGroupInput placeholder="Large" />
|
|
58
|
+
</InputGroup>
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
5
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
const inputGroupVariants = cva(
|
|
9
|
+
"flex items-center w-full rounded-xl border border-border bg-background text-base md:text-sm text-foreground transition-colors focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 has-[input:disabled]:cursor-not-allowed has-[input:disabled]:opacity-50 aria-invalid:border-destructive aria-invalid:focus-within:ring-destructive/40",
|
|
10
|
+
{
|
|
11
|
+
variants: {
|
|
12
|
+
size: {
|
|
13
|
+
sm: "h-9",
|
|
14
|
+
default: "h-11",
|
|
15
|
+
lg: "h-12",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
size: "default",
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
type InputGroupContextValue = VariantProps<typeof inputGroupVariants>;
|
|
25
|
+
const InputGroupContext = React.createContext<InputGroupContextValue>({});
|
|
26
|
+
|
|
27
|
+
const InputGroup = React.forwardRef<
|
|
28
|
+
HTMLDivElement,
|
|
29
|
+
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof inputGroupVariants>
|
|
30
|
+
>(({ className, size, children, ...props }, ref) => (
|
|
31
|
+
<InputGroupContext.Provider value={{ size }}>
|
|
32
|
+
<div
|
|
33
|
+
ref={ref}
|
|
34
|
+
data-slot="input-group"
|
|
35
|
+
className={cn(inputGroupVariants({ size }), className)}
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</div>
|
|
40
|
+
</InputGroupContext.Provider>
|
|
41
|
+
));
|
|
42
|
+
InputGroup.displayName = "InputGroup";
|
|
43
|
+
|
|
44
|
+
const InputGroupInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
|
45
|
+
({ className, ...props }, ref) => (
|
|
46
|
+
<input
|
|
47
|
+
ref={ref}
|
|
48
|
+
data-slot="input-group-input"
|
|
49
|
+
className={cn(
|
|
50
|
+
"h-full min-w-0 flex-1 bg-transparent px-3 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
|
|
51
|
+
className
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
InputGroupInput.displayName = "InputGroupInput";
|
|
58
|
+
|
|
59
|
+
const InputGroupAddon = React.forwardRef<
|
|
60
|
+
HTMLDivElement,
|
|
61
|
+
React.HTMLAttributes<HTMLDivElement> & {
|
|
62
|
+
align?: "start" | "end";
|
|
63
|
+
asChild?: boolean;
|
|
64
|
+
}
|
|
65
|
+
>(({ className, align = "start", asChild, ...props }, ref) => {
|
|
66
|
+
const Comp = asChild ? Slot : "div";
|
|
67
|
+
return (
|
|
68
|
+
<Comp
|
|
69
|
+
ref={ref}
|
|
70
|
+
data-slot="input-group-addon"
|
|
71
|
+
data-align={align}
|
|
72
|
+
className={cn(
|
|
73
|
+
"inline-flex h-full items-center gap-1 text-sm text-muted-foreground",
|
|
74
|
+
align === "start" ? "pl-3 pr-1" : "pl-1 pr-3",
|
|
75
|
+
className
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
InputGroupAddon.displayName = "InputGroupAddon";
|
|
82
|
+
|
|
83
|
+
const InputGroupButton = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
|
|
84
|
+
({ className, type = "button", ...props }, ref) => (
|
|
85
|
+
<button
|
|
86
|
+
ref={ref}
|
|
87
|
+
type={type}
|
|
88
|
+
data-slot="input-group-button"
|
|
89
|
+
className={cn(
|
|
90
|
+
"inline-flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&:has(svg:only-child)]:w-7 [&:has(svg:only-child)]:px-0 [&_svg]:size-4",
|
|
91
|
+
className
|
|
92
|
+
)}
|
|
93
|
+
{...props}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
InputGroupButton.displayName = "InputGroupButton";
|
|
98
|
+
|
|
99
|
+
export { InputGroup, InputGroupInput, InputGroupAddon, InputGroupButton, inputGroupVariants };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { Input, inputVariants } from "./input";
|
|
5
|
+
|
|
6
|
+
// ─── inputVariants CVA ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe("inputVariants", () => {
|
|
9
|
+
it("returns default size classes when called with no args", () => {
|
|
10
|
+
const result = inputVariants({});
|
|
11
|
+
expect(result).toContain("h-11");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("maps size prop to correct classes", () => {
|
|
15
|
+
expect(inputVariants({ size: "sm" })).toContain("h-9");
|
|
16
|
+
expect(inputVariants({ size: "default" })).toContain("h-11");
|
|
17
|
+
expect(inputVariants({ size: "lg" })).toContain("h-12");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("includes aria-invalid styles in base classes", () => {
|
|
21
|
+
const result = inputVariants({});
|
|
22
|
+
expect(result).toContain("aria-invalid:border-destructive");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ─── <Input> component ────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("Input component", () => {
|
|
29
|
+
it("renders as <input> by default", () => {
|
|
30
|
+
render(<Input placeholder="enter" />);
|
|
31
|
+
const el = screen.getByPlaceholderText("enter");
|
|
32
|
+
expect(el.tagName).toBe("INPUT");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("applies data-slot=input attribute", () => {
|
|
36
|
+
render(<Input data-testid="i" />);
|
|
37
|
+
expect(screen.getByTestId("i").getAttribute("data-slot")).toBe("input");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("forwards ref to the underlying input element", () => {
|
|
41
|
+
const ref = { current: null as HTMLInputElement | null };
|
|
42
|
+
render(<Input ref={ref} />);
|
|
43
|
+
expect(ref.current).toBeInstanceOf(HTMLInputElement);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("respects type prop", () => {
|
|
47
|
+
render(<Input type="email" data-testid="i" />);
|
|
48
|
+
expect((screen.getByTestId("i") as HTMLInputElement).type).toBe("email");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("accepts user input", async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
render(<Input data-testid="i" />);
|
|
54
|
+
const el = screen.getByTestId("i") as HTMLInputElement;
|
|
55
|
+
await user.type(el, "hello");
|
|
56
|
+
expect(el.value).toBe("hello");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("calls onChange handler", async () => {
|
|
60
|
+
const onChange = vi.fn();
|
|
61
|
+
const user = userEvent.setup();
|
|
62
|
+
render(<Input data-testid="i" onChange={onChange} />);
|
|
63
|
+
await user.type(screen.getByTestId("i"), "a");
|
|
64
|
+
expect(onChange).toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("respects disabled prop", async () => {
|
|
68
|
+
const onChange = vi.fn();
|
|
69
|
+
const user = userEvent.setup();
|
|
70
|
+
render(<Input data-testid="i" disabled onChange={onChange} />);
|
|
71
|
+
const el = screen.getByTestId("i") as HTMLInputElement;
|
|
72
|
+
expect(el.disabled).toBe(true);
|
|
73
|
+
await user.type(el, "x");
|
|
74
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("wires aria-invalid for invalid state", () => {
|
|
78
|
+
render(<Input data-testid="i" aria-invalid="true" />);
|
|
79
|
+
expect(screen.getByTestId("i").getAttribute("aria-invalid")).toBe("true");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("respects controlled value", () => {
|
|
83
|
+
render(<Input data-testid="i" value="hello" onChange={() => {}} />);
|
|
84
|
+
expect((screen.getByTestId("i") as HTMLInputElement).value).toBe("hello");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("applies size class from size prop", () => {
|
|
88
|
+
render(<Input data-testid="i" size="sm" />);
|
|
89
|
+
expect(screen.getByTestId("i").className).toContain("h-9");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("merges className via twMerge", () => {
|
|
93
|
+
render(<Input data-testid="i" className="text-red-500" />);
|
|
94
|
+
expect(screen.getByTestId("i").className).toContain("text-red-500");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("passes through name and id", () => {
|
|
98
|
+
render(<Input id="email" name="email" data-testid="i" />);
|
|
99
|
+
const el = screen.getByTestId("i");
|
|
100
|
+
expect(el.id).toBe("email");
|
|
101
|
+
expect(el.getAttribute("name")).toBe("email");
|
|
102
|
+
});
|
|
103
|
+
});
|
package/src/components/input.tsx
CHANGED
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
3
|
import { cn } from "../lib/utils";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
const inputVariants = cva(
|
|
6
|
+
"flex w-full rounded-xl border border-border bg-input/50 text-base transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:bg-input disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/40",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
size: {
|
|
10
|
+
sm: "h-9 px-3 text-base md:text-sm",
|
|
11
|
+
default: "h-11 px-4 py-2",
|
|
12
|
+
lg: "h-12 px-5 text-base",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: {
|
|
16
|
+
size: "default",
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export interface InputProps
|
|
22
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
23
|
+
VariantProps<typeof inputVariants> {}
|
|
5
24
|
|
|
6
25
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
7
|
-
({ className, type, ...props }, ref) => {
|
|
26
|
+
({ className, type, size, ...props }, ref) => {
|
|
8
27
|
return (
|
|
9
28
|
<input
|
|
10
29
|
type={type}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
className
|
|
14
|
-
)}
|
|
30
|
+
data-slot="input"
|
|
31
|
+
className={cn(inputVariants({ size }), className)}
|
|
15
32
|
ref={ref}
|
|
16
33
|
{...props}
|
|
17
34
|
/>
|
|
@@ -20,4 +37,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
20
37
|
);
|
|
21
38
|
Input.displayName = "Input";
|
|
22
39
|
|
|
23
|
-
export { Input };
|
|
40
|
+
export { Input, inputVariants };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Mail, ChevronRight } from "lucide-react";
|
|
3
|
+
import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "./item";
|
|
4
|
+
import { Avatar, AvatarFallback } from "./avatar";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Components/Item",
|
|
8
|
+
component: Item,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof Item>;
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof meta>;
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<Item interactive className="w-80">
|
|
18
|
+
<ItemMedia>
|
|
19
|
+
<Mail className="size-5" />
|
|
20
|
+
</ItemMedia>
|
|
21
|
+
<ItemContent>
|
|
22
|
+
<ItemTitle>Inbox</ItemTitle>
|
|
23
|
+
<ItemDescription>3 new messages</ItemDescription>
|
|
24
|
+
</ItemContent>
|
|
25
|
+
<ItemActions>
|
|
26
|
+
<ChevronRight className="size-4 text-muted-foreground" />
|
|
27
|
+
</ItemActions>
|
|
28
|
+
</Item>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const Variants: Story = {
|
|
33
|
+
render: () => (
|
|
34
|
+
<div className="flex w-80 flex-col gap-2">
|
|
35
|
+
<Item variant="default" interactive>
|
|
36
|
+
<ItemMedia>
|
|
37
|
+
<Avatar size="sm"><AvatarFallback>AS</AvatarFallback></Avatar>
|
|
38
|
+
</ItemMedia>
|
|
39
|
+
<ItemContent>
|
|
40
|
+
<ItemTitle>Alice Smith</ItemTitle>
|
|
41
|
+
<ItemDescription>Online</ItemDescription>
|
|
42
|
+
</ItemContent>
|
|
43
|
+
</Item>
|
|
44
|
+
<Item variant="muted">
|
|
45
|
+
<ItemContent><ItemTitle>Muted background</ItemTitle></ItemContent>
|
|
46
|
+
</Item>
|
|
47
|
+
<Item variant="outline">
|
|
48
|
+
<ItemContent><ItemTitle>Outline border</ItemTitle></ItemContent>
|
|
49
|
+
</Item>
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
};
|