@bearmenu/ui 0.5.1 → 0.6.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.
- 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 +5 -2
- 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 +4 -2
- 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 +29 -16
- 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/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 +15 -13
- 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/sticky-container.js +1 -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 +3 -3
- 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 +44 -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/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 +14 -12
- 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/sticky-container.tsx +1 -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 +142 -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,157 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import {
|
|
4
|
+
Field,
|
|
5
|
+
FieldLabel,
|
|
6
|
+
FieldControl,
|
|
7
|
+
FieldDescription,
|
|
8
|
+
FieldError,
|
|
9
|
+
FieldSet,
|
|
10
|
+
FieldLegend,
|
|
11
|
+
} from "./field";
|
|
12
|
+
import { Input } from "./input";
|
|
13
|
+
|
|
14
|
+
describe("Field a11y wiring", () => {
|
|
15
|
+
it("auto-generates an id and wires label htmlFor to control id", () => {
|
|
16
|
+
render(
|
|
17
|
+
<Field>
|
|
18
|
+
<FieldLabel>Email</FieldLabel>
|
|
19
|
+
<FieldControl>
|
|
20
|
+
<Input data-testid="i" />
|
|
21
|
+
</FieldControl>
|
|
22
|
+
</Field>
|
|
23
|
+
);
|
|
24
|
+
const label = screen.getByText("Email") as HTMLLabelElement;
|
|
25
|
+
const input = screen.getByTestId("i");
|
|
26
|
+
const id = input.id;
|
|
27
|
+
expect(id).toBeTruthy();
|
|
28
|
+
expect(label.getAttribute("for")).toBe(id);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("uses a provided id over the generated one", () => {
|
|
32
|
+
render(
|
|
33
|
+
<Field id="custom-id">
|
|
34
|
+
<FieldLabel>L</FieldLabel>
|
|
35
|
+
<FieldControl>
|
|
36
|
+
<Input data-testid="i" />
|
|
37
|
+
</FieldControl>
|
|
38
|
+
</Field>
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByTestId("i").id).toBe("custom-id");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("wires aria-describedby to the description by default", () => {
|
|
44
|
+
render(
|
|
45
|
+
<Field id="f1">
|
|
46
|
+
<FieldControl>
|
|
47
|
+
<Input data-testid="i" />
|
|
48
|
+
</FieldControl>
|
|
49
|
+
<FieldDescription>Helper text</FieldDescription>
|
|
50
|
+
</Field>
|
|
51
|
+
);
|
|
52
|
+
const input = screen.getByTestId("i");
|
|
53
|
+
const desc = screen.getByText("Helper text");
|
|
54
|
+
expect(desc.id).toBe("f1-description");
|
|
55
|
+
expect(input.getAttribute("aria-describedby")).toBe("f1-description");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders FieldError and wires aria-invalid + aria-describedby when invalid", () => {
|
|
59
|
+
render(
|
|
60
|
+
<Field id="f2" invalid>
|
|
61
|
+
<FieldControl>
|
|
62
|
+
<Input data-testid="i" />
|
|
63
|
+
</FieldControl>
|
|
64
|
+
<FieldDescription>Helper</FieldDescription>
|
|
65
|
+
<FieldError>Required</FieldError>
|
|
66
|
+
</Field>
|
|
67
|
+
);
|
|
68
|
+
const input = screen.getByTestId("i");
|
|
69
|
+
const err = screen.getByRole("alert");
|
|
70
|
+
expect(input.getAttribute("aria-invalid")).toBe("true");
|
|
71
|
+
expect(err.textContent).toBe("Required");
|
|
72
|
+
expect(err.id).toBe("f2-error");
|
|
73
|
+
expect(input.getAttribute("aria-describedby")).toBe("f2-description f2-error");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not render FieldError when not invalid", () => {
|
|
77
|
+
render(
|
|
78
|
+
<Field id="f3">
|
|
79
|
+
<FieldControl>
|
|
80
|
+
<Input data-testid="i" />
|
|
81
|
+
</FieldControl>
|
|
82
|
+
<FieldError>Error msg</FieldError>
|
|
83
|
+
</Field>
|
|
84
|
+
);
|
|
85
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not render FieldError when invalid but no children", () => {
|
|
89
|
+
render(
|
|
90
|
+
<Field id="f4" invalid>
|
|
91
|
+
<FieldControl>
|
|
92
|
+
<Input data-testid="i" />
|
|
93
|
+
</FieldControl>
|
|
94
|
+
<FieldError>{null}</FieldError>
|
|
95
|
+
</Field>
|
|
96
|
+
);
|
|
97
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("applies destructive class to label when invalid", () => {
|
|
101
|
+
render(
|
|
102
|
+
<Field invalid>
|
|
103
|
+
<FieldLabel>L</FieldLabel>
|
|
104
|
+
<FieldControl>
|
|
105
|
+
<Input />
|
|
106
|
+
</FieldControl>
|
|
107
|
+
</Field>
|
|
108
|
+
);
|
|
109
|
+
expect(screen.getByText("L").className).toContain("text-destructive");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("sets data-invalid on the field wrapper when invalid", () => {
|
|
113
|
+
render(
|
|
114
|
+
<Field invalid data-testid="wrapper">
|
|
115
|
+
<FieldControl>
|
|
116
|
+
<Input />
|
|
117
|
+
</FieldControl>
|
|
118
|
+
</Field>
|
|
119
|
+
);
|
|
120
|
+
expect(screen.getByTestId("wrapper").getAttribute("data-invalid")).toBe("true");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("applies horizontal orientation class", () => {
|
|
124
|
+
render(
|
|
125
|
+
<Field orientation="horizontal" data-testid="wrapper">
|
|
126
|
+
<FieldControl>
|
|
127
|
+
<Input />
|
|
128
|
+
</FieldControl>
|
|
129
|
+
</Field>
|
|
130
|
+
);
|
|
131
|
+
expect(screen.getByTestId("wrapper").className).toContain("flex-row");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("applies vertical orientation class by default", () => {
|
|
135
|
+
render(
|
|
136
|
+
<Field data-testid="wrapper">
|
|
137
|
+
<FieldControl>
|
|
138
|
+
<Input />
|
|
139
|
+
</FieldControl>
|
|
140
|
+
</Field>
|
|
141
|
+
);
|
|
142
|
+
expect(screen.getByTestId("wrapper").className).toContain("flex-col");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("FieldSet / FieldLegend", () => {
|
|
147
|
+
it("renders fieldset + legend correctly", () => {
|
|
148
|
+
render(
|
|
149
|
+
<FieldSet data-testid="fs">
|
|
150
|
+
<FieldLegend>Group</FieldLegend>
|
|
151
|
+
</FieldSet>
|
|
152
|
+
);
|
|
153
|
+
const fs = screen.getByTestId("fs");
|
|
154
|
+
expect(fs.tagName).toBe("FIELDSET");
|
|
155
|
+
expect(screen.getByText("Group").tagName).toBe("LEGEND");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -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 };
|