@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,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
|
+
});
|
|
@@ -5,7 +5,7 @@ import type { DialogProps } from "@radix-ui/react-dialog";
|
|
|
5
5
|
import { Command as CommandPrimitive } from "cmdk";
|
|
6
6
|
import { Search } from "lucide-react";
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
|
-
import { Dialog, DialogContent } from "./dialog";
|
|
8
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./dialog";
|
|
9
9
|
|
|
10
10
|
const Command = React.forwardRef<
|
|
11
11
|
React.ElementRef<typeof CommandPrimitive>,
|
|
@@ -13,8 +13,9 @@ const Command = React.forwardRef<
|
|
|
13
13
|
>(({ className, ...props }, ref) => (
|
|
14
14
|
<CommandPrimitive
|
|
15
15
|
ref={ref}
|
|
16
|
+
data-slot="command"
|
|
16
17
|
className={cn(
|
|
17
|
-
"flex h-full w-full flex-col overflow-hidden rounded-xl
|
|
18
|
+
"flex h-full w-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground",
|
|
18
19
|
className
|
|
19
20
|
)}
|
|
20
21
|
{...props}
|
|
@@ -22,12 +23,24 @@ const Command = React.forwardRef<
|
|
|
22
23
|
));
|
|
23
24
|
Command.displayName = CommandPrimitive.displayName;
|
|
24
25
|
|
|
25
|
-
type CommandDialogProps = DialogProps
|
|
26
|
+
type CommandDialogProps = DialogProps & {
|
|
27
|
+
title?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
};
|
|
26
30
|
|
|
27
|
-
const CommandDialog = ({
|
|
31
|
+
const CommandDialog = ({
|
|
32
|
+
title = "Command palette",
|
|
33
|
+
description = "Search for commands and actions",
|
|
34
|
+
children,
|
|
35
|
+
...props
|
|
36
|
+
}: CommandDialogProps) => {
|
|
28
37
|
return (
|
|
29
38
|
<Dialog {...props}>
|
|
30
|
-
<DialogContent className="overflow-hidden p-0
|
|
39
|
+
<DialogContent className="overflow-hidden p-0">
|
|
40
|
+
<DialogHeader className="sr-only">
|
|
41
|
+
<DialogTitle>{title}</DialogTitle>
|
|
42
|
+
<DialogDescription>{description}</DialogDescription>
|
|
43
|
+
</DialogHeader>
|
|
31
44
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
|
32
45
|
{children}
|
|
33
46
|
</Command>
|
|
@@ -40,19 +53,18 @@ const CommandInput = React.forwardRef<
|
|
|
40
53
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
|
41
54
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
|
42
55
|
>(({ className, ...props }, ref) => (
|
|
43
|
-
<div className="flex items-center border-b border-border
|
|
44
|
-
<Search className="mr-2
|
|
56
|
+
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
|
|
57
|
+
<Search className="mr-2 size-4 shrink-0 opacity-50" />
|
|
45
58
|
<CommandPrimitive.Input
|
|
46
59
|
ref={ref}
|
|
47
60
|
className={cn(
|
|
48
|
-
"flex h-11 w-full rounded-md bg-transparent py-3 text-
|
|
61
|
+
"flex h-11 w-full rounded-md bg-transparent py-3 text-base outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
49
62
|
className
|
|
50
63
|
)}
|
|
51
64
|
{...props}
|
|
52
65
|
/>
|
|
53
66
|
</div>
|
|
54
67
|
));
|
|
55
|
-
|
|
56
68
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
|
57
69
|
|
|
58
70
|
const CommandList = React.forwardRef<
|
|
@@ -65,7 +77,6 @@ const CommandList = React.forwardRef<
|
|
|
65
77
|
{...props}
|
|
66
78
|
/>
|
|
67
79
|
));
|
|
68
|
-
|
|
69
80
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
|
70
81
|
|
|
71
82
|
const CommandEmpty = React.forwardRef<
|
|
@@ -74,13 +85,24 @@ const CommandEmpty = React.forwardRef<
|
|
|
74
85
|
>(({ className, ...props }, ref) => (
|
|
75
86
|
<CommandPrimitive.Empty
|
|
76
87
|
ref={ref}
|
|
77
|
-
className={cn("py-6 text-center text-sm", className)}
|
|
88
|
+
className={cn("py-6 text-center text-sm text-muted-foreground", className)}
|
|
78
89
|
{...props}
|
|
79
90
|
/>
|
|
80
91
|
));
|
|
81
|
-
|
|
82
92
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
|
83
93
|
|
|
94
|
+
const CommandLoading = React.forwardRef<
|
|
95
|
+
React.ElementRef<typeof CommandPrimitive.Loading>,
|
|
96
|
+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Loading>
|
|
97
|
+
>(({ className, ...props }, ref) => (
|
|
98
|
+
<CommandPrimitive.Loading
|
|
99
|
+
ref={ref}
|
|
100
|
+
className={cn("py-6 text-center text-sm text-muted-foreground", className)}
|
|
101
|
+
{...props}
|
|
102
|
+
/>
|
|
103
|
+
));
|
|
104
|
+
CommandLoading.displayName = "CommandLoading";
|
|
105
|
+
|
|
84
106
|
const CommandGroup = React.forwardRef<
|
|
85
107
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
|
86
108
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
|
@@ -94,7 +116,6 @@ const CommandGroup = React.forwardRef<
|
|
|
94
116
|
{...props}
|
|
95
117
|
/>
|
|
96
118
|
));
|
|
97
|
-
|
|
98
119
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
|
99
120
|
|
|
100
121
|
const CommandSeparator = React.forwardRef<
|
|
@@ -116,13 +137,14 @@ const CommandItem = React.forwardRef<
|
|
|
116
137
|
<CommandPrimitive.Item
|
|
117
138
|
ref={ref}
|
|
118
139
|
className={cn(
|
|
119
|
-
"relative flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none
|
|
140
|
+
"relative flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none transition-colors",
|
|
141
|
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
|
|
142
|
+
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
|
120
143
|
className
|
|
121
144
|
)}
|
|
122
145
|
{...props}
|
|
123
146
|
/>
|
|
124
147
|
));
|
|
125
|
-
|
|
126
148
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
|
127
149
|
|
|
128
150
|
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
@@ -141,6 +163,7 @@ export {
|
|
|
141
163
|
CommandInput,
|
|
142
164
|
CommandList,
|
|
143
165
|
CommandEmpty,
|
|
166
|
+
CommandLoading,
|
|
144
167
|
CommandGroup,
|
|
145
168
|
CommandItem,
|
|
146
169
|
CommandShortcut,
|
|
@@ -0,0 +1,63 @@
|
|
|
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 { CurrencyInput } from "./currency-input";
|
|
5
|
+
|
|
6
|
+
describe("CurrencyInput", () => {
|
|
7
|
+
it("renders with the default `$` symbol", () => {
|
|
8
|
+
render(<CurrencyInput value={null} onChange={() => {}} />);
|
|
9
|
+
expect(screen.getByText("$")).toBeDefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("renders a custom currency symbol", () => {
|
|
13
|
+
render(<CurrencyInput value={null} onChange={() => {}} currency="RON" />);
|
|
14
|
+
expect(screen.getByText("RON")).toBeDefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("widens left padding for multi-char currency symbols", () => {
|
|
18
|
+
render(<CurrencyInput value={null} onChange={() => {}} currency="RON" />);
|
|
19
|
+
expect(screen.getByRole("textbox").className).toContain("pl-12");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("formats the blurred value with locale separators", () => {
|
|
23
|
+
render(<CurrencyInput value={1234.5} onChange={() => {}} />);
|
|
24
|
+
expect(screen.getByRole("textbox")).toHaveValue("1,234.5");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("fires onChange with a parsed number when typing", async () => {
|
|
28
|
+
const user = userEvent.setup();
|
|
29
|
+
const onChange = vi.fn();
|
|
30
|
+
render(<CurrencyInput value={null} onChange={onChange} />);
|
|
31
|
+
const input = screen.getByRole("textbox");
|
|
32
|
+
await user.type(input, "42");
|
|
33
|
+
expect(onChange).toHaveBeenLastCalledWith(42);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders an error message and links it via aria-describedby", () => {
|
|
37
|
+
render(
|
|
38
|
+
<CurrencyInput value={null} onChange={() => {}} error="Required" id="amt" />
|
|
39
|
+
);
|
|
40
|
+
const input = screen.getByRole("textbox");
|
|
41
|
+
expect(input).toHaveAttribute("aria-invalid", "true");
|
|
42
|
+
expect(input).toHaveAttribute("aria-describedby", "amt-error");
|
|
43
|
+
expect(screen.getByText("Required").id).toBe("amt-error");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not set aria-invalid when no error", () => {
|
|
47
|
+
render(<CurrencyInput value={null} onChange={() => {}} />);
|
|
48
|
+
expect(screen.getByRole("textbox")).not.toHaveAttribute("aria-invalid");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("blocks non-numeric character entry", async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
const onChange = vi.fn();
|
|
54
|
+
render(<CurrencyInput value={null} onChange={onChange} />);
|
|
55
|
+
await user.type(screen.getByRole("textbox"), "abc");
|
|
56
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("respects disabled", () => {
|
|
60
|
+
render(<CurrencyInput value={null} onChange={() => {}} disabled />);
|
|
61
|
+
expect(screen.getByRole("textbox")).toBeDisabled();
|
|
62
|
+
});
|
|
63
|
+
});
|