@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,58 @@
|
|
|
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 { Checkbox } from "./checkbox";
|
|
5
|
+
|
|
6
|
+
describe("Checkbox", () => {
|
|
7
|
+
it("renders an unchecked checkbox by default", () => {
|
|
8
|
+
render(<Checkbox aria-label="Accept" />);
|
|
9
|
+
const cb = screen.getByRole("checkbox", { name: /accept/i });
|
|
10
|
+
expect(cb).toHaveAttribute("data-state", "unchecked");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders checked when `checked` is true", () => {
|
|
14
|
+
render(<Checkbox aria-label="Accept" checked onCheckedChange={() => {}} />);
|
|
15
|
+
expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", "checked");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders indeterminate state", () => {
|
|
19
|
+
render(<Checkbox aria-label="x" checked="indeterminate" onCheckedChange={() => {}} />);
|
|
20
|
+
expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", "indeterminate");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("calls onCheckedChange when clicked", async () => {
|
|
24
|
+
const user = userEvent.setup();
|
|
25
|
+
const onCheckedChange = vi.fn();
|
|
26
|
+
render(<Checkbox aria-label="x" onCheckedChange={onCheckedChange} />);
|
|
27
|
+
await user.click(screen.getByRole("checkbox"));
|
|
28
|
+
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("calls onCheckedChange via Space key", async () => {
|
|
32
|
+
const user = userEvent.setup();
|
|
33
|
+
const onCheckedChange = vi.fn();
|
|
34
|
+
render(<Checkbox aria-label="x" onCheckedChange={onCheckedChange} />);
|
|
35
|
+
const cb = screen.getByRole("checkbox");
|
|
36
|
+
cb.focus();
|
|
37
|
+
await user.keyboard(" ");
|
|
38
|
+
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not fire when disabled", async () => {
|
|
42
|
+
const user = userEvent.setup();
|
|
43
|
+
const onCheckedChange = vi.fn();
|
|
44
|
+
render(<Checkbox aria-label="x" disabled onCheckedChange={onCheckedChange} />);
|
|
45
|
+
await user.click(screen.getByRole("checkbox"));
|
|
46
|
+
expect(onCheckedChange).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("forwards the aria-invalid attribute", () => {
|
|
50
|
+
render(<Checkbox aria-label="x" aria-invalid />);
|
|
51
|
+
expect(screen.getByRole("checkbox")).toHaveAttribute("aria-invalid", "true");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("merges className", () => {
|
|
55
|
+
render(<Checkbox aria-label="x" className="custom-class" />);
|
|
56
|
+
expect(screen.getByRole("checkbox").className).toContain("custom-class");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
|
5
|
-
import { Check } from "lucide-react";
|
|
5
|
+
import { Check, Minus } from "lucide-react";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
|
|
8
8
|
const Checkbox = React.forwardRef<
|
|
@@ -11,14 +11,19 @@ const Checkbox = React.forwardRef<
|
|
|
11
11
|
>(({ className, ...props }, ref) => (
|
|
12
12
|
<CheckboxPrimitive.Root
|
|
13
13
|
ref={ref}
|
|
14
|
+
data-slot="checkbox"
|
|
14
15
|
className={cn(
|
|
15
|
-
"peer
|
|
16
|
+
"peer size-5 shrink-0 rounded-md border border-border bg-background transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary hover:border-primary/50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/40",
|
|
16
17
|
className
|
|
17
18
|
)}
|
|
18
19
|
{...props}
|
|
19
20
|
>
|
|
20
21
|
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
|
21
|
-
|
|
22
|
+
{props.checked === "indeterminate" ? (
|
|
23
|
+
<Minus className="size-3.5 animate-in fade-in-0 zoom-in-50 duration-200 ease-out" strokeWidth={3} />
|
|
24
|
+
) : (
|
|
25
|
+
<Check className="size-3.5 animate-in fade-in-0 zoom-in-50 duration-200 ease-out" strokeWidth={3} />
|
|
26
|
+
)}
|
|
22
27
|
</CheckboxPrimitive.Indicator>
|
|
23
28
|
</CheckboxPrimitive.Root>
|
|
24
29
|
));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { CircularProgress } from "./circular-progress";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Components/CircularProgress",
|
|
6
|
+
component: CircularProgress,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
} satisfies Meta<typeof CircularProgress>;
|
|
9
|
+
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = { args: { value: 60, showLabel: true } };
|
|
14
|
+
|
|
15
|
+
export const Sizes: Story = {
|
|
16
|
+
render: () => (
|
|
17
|
+
<div className="flex items-center gap-4">
|
|
18
|
+
<CircularProgress value={75} size={24} />
|
|
19
|
+
<CircularProgress value={75} size={40} />
|
|
20
|
+
<CircularProgress value={75} size={56} showLabel />
|
|
21
|
+
<CircularProgress value={75} size={80} showLabel strokeWidth={6} />
|
|
22
|
+
</div>
|
|
23
|
+
),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Tones: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<div className="flex items-center gap-4">
|
|
29
|
+
<CircularProgress value={60} tone="primary" showLabel />
|
|
30
|
+
<CircularProgress value={60} tone="success" showLabel />
|
|
31
|
+
<CircularProgress value={60} tone="warning" showLabel />
|
|
32
|
+
<CircularProgress value={60} tone="destructive" showLabel />
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const Indeterminate: Story = {
|
|
38
|
+
args: { indeterminate: true, size: 40 },
|
|
39
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
type CircularProgressProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
7
|
+
value?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
size?: number;
|
|
10
|
+
strokeWidth?: number;
|
|
11
|
+
tone?: "primary" | "success" | "warning" | "destructive";
|
|
12
|
+
showLabel?: boolean;
|
|
13
|
+
/** Indeterminate spinner mode. */
|
|
14
|
+
indeterminate?: boolean;
|
|
15
|
+
/** Accessible label. Defaults to "Progress". */
|
|
16
|
+
label?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const toneClasses = {
|
|
20
|
+
primary: "stroke-primary",
|
|
21
|
+
success: "stroke-success",
|
|
22
|
+
warning: "stroke-warning",
|
|
23
|
+
destructive: "stroke-destructive",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const CircularProgress = React.forwardRef<HTMLDivElement, CircularProgressProps>(
|
|
27
|
+
(
|
|
28
|
+
{
|
|
29
|
+
className,
|
|
30
|
+
value = 0,
|
|
31
|
+
max = 100,
|
|
32
|
+
size = 40,
|
|
33
|
+
strokeWidth = 4,
|
|
34
|
+
tone = "primary",
|
|
35
|
+
showLabel = false,
|
|
36
|
+
indeterminate = false,
|
|
37
|
+
label = "Progress",
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
ref
|
|
41
|
+
) => {
|
|
42
|
+
const radius = (size - strokeWidth) / 2;
|
|
43
|
+
const circumference = 2 * Math.PI * radius;
|
|
44
|
+
const clampedValue = Math.min(Math.max(value, 0), max);
|
|
45
|
+
const percent = (clampedValue / max) * 100;
|
|
46
|
+
const dashOffset = circumference - (circumference * percent) / 100;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
ref={ref}
|
|
51
|
+
data-slot="circular-progress"
|
|
52
|
+
role="progressbar"
|
|
53
|
+
aria-valuemin={0}
|
|
54
|
+
aria-valuemax={max}
|
|
55
|
+
aria-valuenow={indeterminate ? undefined : clampedValue}
|
|
56
|
+
aria-label={label}
|
|
57
|
+
className={cn("relative inline-flex shrink-0 items-center justify-center", className)}
|
|
58
|
+
style={{ width: size, height: size }}
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
<svg
|
|
62
|
+
width={size}
|
|
63
|
+
height={size}
|
|
64
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
65
|
+
className={cn(indeterminate && "animate-spin")}
|
|
66
|
+
>
|
|
67
|
+
<circle
|
|
68
|
+
cx={size / 2}
|
|
69
|
+
cy={size / 2}
|
|
70
|
+
r={radius}
|
|
71
|
+
strokeWidth={strokeWidth}
|
|
72
|
+
fill="none"
|
|
73
|
+
className="stroke-muted"
|
|
74
|
+
/>
|
|
75
|
+
<circle
|
|
76
|
+
cx={size / 2}
|
|
77
|
+
cy={size / 2}
|
|
78
|
+
r={radius}
|
|
79
|
+
strokeWidth={strokeWidth}
|
|
80
|
+
fill="none"
|
|
81
|
+
strokeLinecap="round"
|
|
82
|
+
className={cn("transition-[stroke-dashoffset] duration-300 ease-out", toneClasses[tone])}
|
|
83
|
+
style={{
|
|
84
|
+
strokeDasharray: circumference,
|
|
85
|
+
strokeDashoffset: indeterminate ? circumference * 0.75 : dashOffset,
|
|
86
|
+
transform: `rotate(-90deg)`,
|
|
87
|
+
transformOrigin: `${size / 2}px ${size / 2}px`,
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
</svg>
|
|
91
|
+
{showLabel && !indeterminate && (
|
|
92
|
+
<span className="absolute text-xs font-medium tabular-nums text-foreground">
|
|
93
|
+
{Math.round(percent)}%
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
CircularProgress.displayName = "CircularProgress";
|
|
101
|
+
|
|
102
|
+
export { CircularProgress };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
|
|
3
3
|
import { Button } from "./button";
|
|
4
|
-
import {
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
5
|
|
|
6
6
|
const meta = {
|
|
7
7
|
title: "Components/Collapsible",
|
|
@@ -18,8 +18,12 @@ export const Default: Story = {
|
|
|
18
18
|
<div className="flex items-center justify-between space-x-4 px-4">
|
|
19
19
|
<h4 className="text-sm font-semibold">@peduarte starred 3 repositories</h4>
|
|
20
20
|
<CollapsibleTrigger asChild>
|
|
21
|
-
<Button
|
|
22
|
-
|
|
21
|
+
<Button
|
|
22
|
+
variant="ghost"
|
|
23
|
+
size="sm"
|
|
24
|
+
className="w-9 p-0 [&[data-state=open]>svg]:rotate-180"
|
|
25
|
+
>
|
|
26
|
+
<ChevronDown className="h-4 w-4 transition-transform duration-200" />
|
|
23
27
|
<span className="sr-only">Toggle</span>
|
|
24
28
|
</Button>
|
|
25
29
|
</CollapsibleTrigger>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Combobox, type ComboboxOption } from "./combobox";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Components/Combobox",
|
|
7
|
+
component: Combobox,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
} satisfies Meta<typeof Combobox>;
|
|
10
|
+
|
|
11
|
+
export default meta;
|
|
12
|
+
type Story = StoryObj<typeof meta>;
|
|
13
|
+
|
|
14
|
+
const frameworks: ComboboxOption[] = [
|
|
15
|
+
{ value: "next", label: "Next.js", description: "React framework" },
|
|
16
|
+
{ value: "remix", label: "Remix", description: "Full-stack web framework" },
|
|
17
|
+
{ value: "astro", label: "Astro", description: "Content-driven web framework" },
|
|
18
|
+
{ value: "vite", label: "Vite", description: "Frontend tooling" },
|
|
19
|
+
{ value: "nuxt", label: "Nuxt", description: "Vue framework" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const Default: Story = {
|
|
23
|
+
render: () => {
|
|
24
|
+
const [value, setValue] = useState<string | undefined>("next");
|
|
25
|
+
return (
|
|
26
|
+
<div className="w-72">
|
|
27
|
+
<Combobox
|
|
28
|
+
options={frameworks}
|
|
29
|
+
value={value}
|
|
30
|
+
onValueChange={setValue}
|
|
31
|
+
placeholder="Pick a framework"
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const Empty: Story = {
|
|
39
|
+
render: () => {
|
|
40
|
+
const [value, setValue] = useState<string | undefined>(undefined);
|
|
41
|
+
return (
|
|
42
|
+
<div className="w-72">
|
|
43
|
+
<Combobox
|
|
44
|
+
options={frameworks}
|
|
45
|
+
value={value}
|
|
46
|
+
onValueChange={setValue}
|
|
47
|
+
placeholder="No selection"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const Disabled: Story = {
|
|
55
|
+
render: () => (
|
|
56
|
+
<div className="w-72">
|
|
57
|
+
<Combobox
|
|
58
|
+
options={frameworks}
|
|
59
|
+
value="next"
|
|
60
|
+
onValueChange={() => {}}
|
|
61
|
+
disabled
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const Loading: Story = {
|
|
68
|
+
render: () => {
|
|
69
|
+
const [value, setValue] = useState<string | undefined>(undefined);
|
|
70
|
+
return (
|
|
71
|
+
<div className="w-72">
|
|
72
|
+
<Combobox
|
|
73
|
+
options={[]}
|
|
74
|
+
value={value}
|
|
75
|
+
onValueChange={setValue}
|
|
76
|
+
loading
|
|
77
|
+
placeholder="Pick something…"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
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 * as React from "react";
|
|
5
|
+
import { Combobox, type ComboboxOption } from "./combobox";
|
|
6
|
+
|
|
7
|
+
const options: ComboboxOption[] = [
|
|
8
|
+
{ value: "apple", label: "Apple" },
|
|
9
|
+
{ value: "banana", label: "Banana" },
|
|
10
|
+
{ value: "cherry", label: "Cherry", disabled: true },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function Wrap({
|
|
14
|
+
initial,
|
|
15
|
+
...rest
|
|
16
|
+
}: {
|
|
17
|
+
initial?: string;
|
|
18
|
+
clearable?: boolean;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
loading?: boolean;
|
|
21
|
+
onChange?: (v: string | undefined) => void;
|
|
22
|
+
}) {
|
|
23
|
+
const [value, setValue] = React.useState<string | undefined>(initial);
|
|
24
|
+
return (
|
|
25
|
+
<Combobox
|
|
26
|
+
options={options}
|
|
27
|
+
value={value}
|
|
28
|
+
onValueChange={(v) => {
|
|
29
|
+
setValue(v);
|
|
30
|
+
rest.onChange?.(v);
|
|
31
|
+
}}
|
|
32
|
+
clearable={rest.clearable}
|
|
33
|
+
disabled={rest.disabled}
|
|
34
|
+
loading={rest.loading}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("Combobox", () => {
|
|
40
|
+
it("renders placeholder when nothing is selected", () => {
|
|
41
|
+
render(<Wrap />);
|
|
42
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Select...");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("renders selected label when value provided", () => {
|
|
46
|
+
render(<Wrap initial="banana" />);
|
|
47
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Banana");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("opens on trigger click and exposes aria-expanded=true", async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
render(<Wrap />);
|
|
53
|
+
const trigger = screen.getByRole("combobox");
|
|
54
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
55
|
+
await user.click(trigger);
|
|
56
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
57
|
+
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("selects an option and closes the popover", async () => {
|
|
61
|
+
const user = userEvent.setup();
|
|
62
|
+
const onChange = vi.fn();
|
|
63
|
+
render(<Wrap onChange={onChange} />);
|
|
64
|
+
await user.click(screen.getByRole("combobox"));
|
|
65
|
+
await user.click(screen.getByText("Apple"));
|
|
66
|
+
expect(onChange).toHaveBeenCalledWith("apple");
|
|
67
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Apple");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("clears selection when the same value is re-selected", async () => {
|
|
71
|
+
const user = userEvent.setup();
|
|
72
|
+
const onChange = vi.fn();
|
|
73
|
+
render(<Wrap initial="apple" onChange={onChange} />);
|
|
74
|
+
await user.click(screen.getByRole("combobox"));
|
|
75
|
+
await user.click(screen.getByRole("option", { name: /apple/i }));
|
|
76
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("disabled options cannot be selected via click", async () => {
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
const onChange = vi.fn();
|
|
82
|
+
render(<Wrap onChange={onChange} />);
|
|
83
|
+
await user.click(screen.getByRole("combobox"));
|
|
84
|
+
const cherry = screen.getByText("Cherry");
|
|
85
|
+
await user.click(cherry);
|
|
86
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("shows clear button when value is set and clearable=true", async () => {
|
|
90
|
+
render(<Wrap initial="banana" />);
|
|
91
|
+
expect(screen.getByRole("button", { name: "Clear selection" })).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("clear button resets the value", async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
const onChange = vi.fn();
|
|
97
|
+
render(<Wrap initial="banana" onChange={onChange} />);
|
|
98
|
+
await user.click(screen.getByRole("button", { name: "Clear selection" }));
|
|
99
|
+
expect(onChange).toHaveBeenCalledWith(undefined);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("hides clear button when clearable=false", () => {
|
|
103
|
+
render(<Wrap initial="banana" clearable={false} />);
|
|
104
|
+
expect(screen.queryByRole("button", { name: "Clear selection" })).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("disables trigger when disabled=true", () => {
|
|
108
|
+
render(<Wrap initial="banana" disabled />);
|
|
109
|
+
expect((screen.getByRole("combobox") as HTMLButtonElement).disabled).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("shows loading state instead of empty message", async () => {
|
|
113
|
+
const user = userEvent.setup();
|
|
114
|
+
render(<Wrap loading />);
|
|
115
|
+
await user.click(screen.getByRole("combobox"));
|
|
116
|
+
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Check, ChevronsUpDown, X } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import {
|
|
8
|
+
Command,
|
|
9
|
+
CommandEmpty,
|
|
10
|
+
CommandGroup,
|
|
11
|
+
CommandInput,
|
|
12
|
+
CommandItem,
|
|
13
|
+
CommandList,
|
|
14
|
+
CommandLoading,
|
|
15
|
+
} from "./command";
|
|
16
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
17
|
+
|
|
18
|
+
export interface ComboboxOption {
|
|
19
|
+
value: string;
|
|
20
|
+
label: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ComboboxProps {
|
|
26
|
+
options: ComboboxOption[];
|
|
27
|
+
value: string | undefined;
|
|
28
|
+
onValueChange: (value: string | undefined) => void;
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
searchPlaceholder?: string;
|
|
31
|
+
emptyText?: string;
|
|
32
|
+
loadingText?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
loading?: boolean;
|
|
36
|
+
/** Allow clearing the selection. Defaults to true. */
|
|
37
|
+
clearable?: boolean;
|
|
38
|
+
/** If set, external search drives the option list (controlled). */
|
|
39
|
+
onSearchChange?: (search: string) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Combobox({
|
|
43
|
+
options,
|
|
44
|
+
value,
|
|
45
|
+
onValueChange,
|
|
46
|
+
placeholder = "Select...",
|
|
47
|
+
searchPlaceholder = "Search...",
|
|
48
|
+
emptyText = "No results.",
|
|
49
|
+
loadingText = "Loading…",
|
|
50
|
+
className,
|
|
51
|
+
disabled = false,
|
|
52
|
+
loading = false,
|
|
53
|
+
clearable = true,
|
|
54
|
+
onSearchChange,
|
|
55
|
+
}: ComboboxProps) {
|
|
56
|
+
const [open, setOpen] = React.useState(false);
|
|
57
|
+
const selected = options.find((o) => o.value === value);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className={cn("relative", className)}>
|
|
61
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
62
|
+
<PopoverTrigger asChild>
|
|
63
|
+
<Button
|
|
64
|
+
variant="outline"
|
|
65
|
+
role="combobox"
|
|
66
|
+
aria-expanded={open}
|
|
67
|
+
aria-haspopup="listbox"
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
className={cn("w-full justify-between font-normal", !selected && "text-muted-foreground", value && clearable && !disabled && "pr-16")}
|
|
70
|
+
>
|
|
71
|
+
<span className="truncate">{selected?.label || placeholder}</span>
|
|
72
|
+
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
|
|
73
|
+
</Button>
|
|
74
|
+
</PopoverTrigger>
|
|
75
|
+
<PopoverContent
|
|
76
|
+
align="start"
|
|
77
|
+
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
78
|
+
>
|
|
79
|
+
<Command shouldFilter={!onSearchChange}>
|
|
80
|
+
<CommandInput placeholder={searchPlaceholder} onValueChange={onSearchChange} />
|
|
81
|
+
<CommandList>
|
|
82
|
+
{loading ? (
|
|
83
|
+
<CommandLoading>{loadingText}</CommandLoading>
|
|
84
|
+
) : (
|
|
85
|
+
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
86
|
+
)}
|
|
87
|
+
<CommandGroup>
|
|
88
|
+
{options.map((option) => (
|
|
89
|
+
<CommandItem
|
|
90
|
+
key={option.value}
|
|
91
|
+
value={option.value}
|
|
92
|
+
disabled={option.disabled}
|
|
93
|
+
onSelect={() => {
|
|
94
|
+
onValueChange(option.value === value ? undefined : option.value);
|
|
95
|
+
setOpen(false);
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<Check
|
|
99
|
+
className={cn(
|
|
100
|
+
"mr-2 size-4",
|
|
101
|
+
value === option.value ? "opacity-100" : "opacity-0"
|
|
102
|
+
)}
|
|
103
|
+
/>
|
|
104
|
+
<div className="flex min-w-0 flex-col">
|
|
105
|
+
<span className="truncate">{option.label}</span>
|
|
106
|
+
{option.description && (
|
|
107
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
108
|
+
{option.description}
|
|
109
|
+
</span>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</CommandItem>
|
|
113
|
+
))}
|
|
114
|
+
</CommandGroup>
|
|
115
|
+
</CommandList>
|
|
116
|
+
</Command>
|
|
117
|
+
</PopoverContent>
|
|
118
|
+
</Popover>
|
|
119
|
+
{value && clearable && !disabled && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
aria-label="Clear selection"
|
|
123
|
+
onClick={(e) => {
|
|
124
|
+
e.stopPropagation();
|
|
125
|
+
onValueChange(undefined);
|
|
126
|
+
}}
|
|
127
|
+
className="absolute right-9 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
|
128
|
+
>
|
|
129
|
+
<X className="size-3.5" />
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { Combobox };
|
|
@@ -0,0 +1,91 @@
|
|
|
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 {
|
|
5
|
+
Command,
|
|
6
|
+
CommandInput,
|
|
7
|
+
CommandList,
|
|
8
|
+
CommandEmpty,
|
|
9
|
+
CommandGroup,
|
|
10
|
+
CommandItem,
|
|
11
|
+
} from "./command";
|
|
12
|
+
|
|
13
|
+
function CommandSample({ onSelect }: { onSelect?: (v: string) => void }) {
|
|
14
|
+
return (
|
|
15
|
+
<Command>
|
|
16
|
+
<CommandInput placeholder="Search…" />
|
|
17
|
+
<CommandList>
|
|
18
|
+
<CommandEmpty>No results.</CommandEmpty>
|
|
19
|
+
<CommandGroup heading="Fruit">
|
|
20
|
+
<CommandItem value="apple" onSelect={() => onSelect?.("apple")}>
|
|
21
|
+
Apple
|
|
22
|
+
</CommandItem>
|
|
23
|
+
<CommandItem value="banana" onSelect={() => onSelect?.("banana")}>
|
|
24
|
+
Banana
|
|
25
|
+
</CommandItem>
|
|
26
|
+
<CommandItem value="cherry" onSelect={() => onSelect?.("cherry")}>
|
|
27
|
+
Cherry
|
|
28
|
+
</CommandItem>
|
|
29
|
+
</CommandGroup>
|
|
30
|
+
</CommandList>
|
|
31
|
+
</Command>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("Command", () => {
|
|
36
|
+
it("renders input with combobox role and options", () => {
|
|
37
|
+
render(<CommandSample />);
|
|
38
|
+
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getAllByRole("option")).toHaveLength(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("filters items based on input typing", async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
render(<CommandSample />);
|
|
45
|
+
const input = screen.getByPlaceholderText("Search…");
|
|
46
|
+
await user.type(input, "ban");
|
|
47
|
+
const visible = screen
|
|
48
|
+
.getAllByRole("option")
|
|
49
|
+
.filter((el) => el.getAttribute("aria-disabled") !== "true" && el.getAttribute("data-hidden") !== "true");
|
|
50
|
+
expect(visible.map((el) => el.textContent)).toEqual(["Banana"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows empty state when no items match", async () => {
|
|
54
|
+
const user = userEvent.setup();
|
|
55
|
+
render(<CommandSample />);
|
|
56
|
+
await user.type(screen.getByPlaceholderText("Search…"), "xyz");
|
|
57
|
+
expect(screen.getByText("No results.")).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("supports ArrowDown / ArrowUp navigation via aria-selected", async () => {
|
|
61
|
+
const user = userEvent.setup();
|
|
62
|
+
render(<CommandSample />);
|
|
63
|
+
const input = screen.getByRole("combobox");
|
|
64
|
+
input.focus();
|
|
65
|
+
// First item is selected by default
|
|
66
|
+
const options = screen.getAllByRole("option");
|
|
67
|
+
expect(options[0].getAttribute("aria-selected")).toBe("true");
|
|
68
|
+
await user.keyboard("{ArrowDown}");
|
|
69
|
+
expect(screen.getAllByRole("option")[1].getAttribute("aria-selected")).toBe("true");
|
|
70
|
+
await user.keyboard("{ArrowUp}");
|
|
71
|
+
expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe("true");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("calls onSelect when item is clicked", async () => {
|
|
75
|
+
const user = userEvent.setup();
|
|
76
|
+
const onSelect = vi.fn();
|
|
77
|
+
render(<CommandSample onSelect={onSelect} />);
|
|
78
|
+
await user.click(screen.getByText("Banana"));
|
|
79
|
+
expect(onSelect).toHaveBeenCalledWith("banana");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("calls onSelect when Enter is pressed on highlighted item", async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
const onSelect = vi.fn();
|
|
85
|
+
render(<CommandSample onSelect={onSelect} />);
|
|
86
|
+
const input = screen.getByRole("combobox");
|
|
87
|
+
input.focus();
|
|
88
|
+
await user.keyboard("{ArrowDown}{Enter}");
|
|
89
|
+
expect(onSelect).toHaveBeenCalledWith("banana");
|
|
90
|
+
});
|
|
91
|
+
});
|