@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.
Files changed (227) hide show
  1. package/dist/{chunk-ERMIA7NP.js → chunk-7PPCH42H.js} +3 -3
  2. package/dist/{chunk-HLQ2IRE7.js → chunk-BAZFVSSG.js} +3 -3
  3. package/dist/chunk-D5QHEHXJ.js +26 -0
  4. package/dist/{chunk-UISA4CIG.js → chunk-N2KNBPAQ.js} +2 -2
  5. package/dist/{chunk-7H54PTVH.js → chunk-PSR7N36A.js} +38 -10
  6. package/dist/chunk-RIDXFY6M.js +38 -0
  7. package/dist/chunk-UUJVXN2O.js +82 -0
  8. package/dist/chunk-WKMLZDUT.js +122 -0
  9. package/dist/chunk-XK2ZDQVE.js +122 -0
  10. package/dist/{chunk-4KOMHDZD.js → chunk-Y5D5LP4A.js} +18 -3
  11. package/dist/chunk-YJ6OBKZJ.js +32 -0
  12. package/dist/{chunk-ESO2EM3L.js → chunk-ZBBUKXM5.js} +2 -2
  13. package/dist/chunk-ZFNOCP6W.js +106 -0
  14. package/dist/components/accordion.js +11 -5
  15. package/dist/components/alert-dialog.js +5 -3
  16. package/dist/components/alert.d.ts +4 -1
  17. package/dist/components/alert.js +25 -9
  18. package/dist/components/avatar.d.ts +17 -2
  19. package/dist/components/avatar.js +91 -12
  20. package/dist/components/badge.d.ts +1 -1
  21. package/dist/components/badge.js +1 -1
  22. package/dist/components/breadcrumb.js +5 -2
  23. package/dist/components/button-group.d.ts +14 -0
  24. package/dist/components/button-group.js +39 -0
  25. package/dist/components/button.d.ts +3 -1
  26. package/dist/components/button.js +1 -1
  27. package/dist/components/calendar.js +3 -3
  28. package/dist/components/card.d.ts +13 -5
  29. package/dist/components/card.js +2 -2
  30. package/dist/components/carousel.d.ts +20 -3
  31. package/dist/components/carousel.js +72 -12
  32. package/dist/components/checkbox.js +1 -1
  33. package/dist/components/circular-progress.d.ts +16 -0
  34. package/dist/components/circular-progress.js +105 -0
  35. package/dist/components/combobox.d.ts +27 -0
  36. package/dist/components/combobox.js +102 -0
  37. package/dist/components/command.d.ts +16 -3
  38. package/dist/components/command.js +2 -2
  39. package/dist/components/currency-input.d.ts +1 -0
  40. package/dist/components/currency-input.js +73 -52
  41. package/dist/components/date-input.d.ts +10 -0
  42. package/dist/components/date-input.js +40 -0
  43. package/dist/components/description-list.d.ts +15 -0
  44. package/dist/components/description-list.js +67 -0
  45. package/dist/components/dialog.d.ts +13 -2
  46. package/dist/components/dialog.js +1 -1
  47. package/dist/components/drawer.d.ts +5 -2
  48. package/dist/components/drawer.js +47 -9
  49. package/dist/components/dropdown-menu.d.ts +7 -2
  50. package/dist/components/dropdown-menu.js +22 -6
  51. package/dist/components/empty-state.js +2 -2
  52. package/dist/components/field.d.ts +22 -0
  53. package/dist/components/field.js +130 -0
  54. package/dist/components/form.d.ts +2 -1
  55. package/dist/components/form.js +11 -1
  56. package/dist/components/haptic-button.js +3 -3
  57. package/dist/components/input-group.d.ts +18 -0
  58. package/dist/components/input-group.js +92 -0
  59. package/dist/components/input.d.ts +8 -2
  60. package/dist/components/input.js +1 -1
  61. package/dist/components/item.d.ts +23 -0
  62. package/dist/components/item.js +120 -0
  63. package/dist/components/kbd.d.ts +13 -0
  64. package/dist/components/kbd.js +49 -0
  65. package/dist/components/lazy-markdown.d.ts +3 -1
  66. package/dist/components/lazy-markdown.js +7 -7
  67. package/dist/components/link-button.js +1 -1
  68. package/dist/components/multi-select-combobox.js +92 -83
  69. package/dist/components/multi-select-menu.d.ts +0 -4
  70. package/dist/components/multi-select-menu.js +28 -17
  71. package/dist/components/native-select.d.ts +12 -0
  72. package/dist/components/native-select.js +49 -0
  73. package/dist/components/pagination.d.ts +5 -2
  74. package/dist/components/pagination.js +66 -7
  75. package/dist/components/place-about-tab.js +4 -4
  76. package/dist/components/place-card.js +1 -1
  77. package/dist/components/place-details-mobile.js +4 -4
  78. package/dist/components/popover.d.ts +7 -1
  79. package/dist/components/popover.js +1 -1
  80. package/dist/components/progress.d.ts +5 -1
  81. package/dist/components/progress.js +17 -3
  82. package/dist/components/radio-group.js +12 -3
  83. package/dist/components/rating.d.ts +2 -0
  84. package/dist/components/rating.js +88 -70
  85. package/dist/components/scroll-fade.d.ts +15 -0
  86. package/dist/components/scroll-fade.js +116 -0
  87. package/dist/components/search-bar.d.ts +1 -2
  88. package/dist/components/search-bar.js +37 -60
  89. package/dist/components/searchable-select.js +76 -72
  90. package/dist/components/select.d.ts +5 -1
  91. package/dist/components/select.js +30 -14
  92. package/dist/components/selection-bar.d.ts +11 -0
  93. package/dist/components/selection-bar.js +65 -0
  94. package/dist/components/separator.js +3 -2
  95. package/dist/components/sheet.d.ts +1 -1
  96. package/dist/components/sheet.js +1 -1
  97. package/dist/components/skeleton-card.js +1 -1
  98. package/dist/components/skeleton.js +1 -1
  99. package/dist/components/slider.js +21 -4
  100. package/dist/components/sliding-panels.js +9 -6
  101. package/dist/components/spinner.d.ts +17 -0
  102. package/dist/components/spinner.js +50 -0
  103. package/dist/components/stepper.d.ts +18 -0
  104. package/dist/components/stepper.js +199 -0
  105. package/dist/components/switch.js +4 -3
  106. package/dist/components/table.d.ts +23 -5
  107. package/dist/components/table.js +121 -35
  108. package/dist/components/tabs.d.ts +3 -1
  109. package/dist/components/tabs.js +37 -9
  110. package/dist/components/tag-group.d.ts +9 -0
  111. package/dist/components/tag-group.js +26 -0
  112. package/dist/components/tag.d.ts +17 -0
  113. package/dist/components/tag.js +61 -0
  114. package/dist/components/text.d.ts +1 -1
  115. package/dist/components/text.js +1 -1
  116. package/dist/components/textarea.js +2 -1
  117. package/dist/components/toast.js +1 -1
  118. package/dist/components/toaster.js +1 -1
  119. package/dist/components/toggle-group.js +1 -1
  120. package/dist/components/toggle.js +1 -1
  121. package/dist/components/tooltip.d.ts +3 -2
  122. package/dist/components/tooltip.js +16 -2
  123. package/package.json +45 -26
  124. package/src/components/accordion.tsx +11 -6
  125. package/src/components/alert-dialog.tsx +4 -2
  126. package/src/components/alert.tsx +20 -8
  127. package/src/components/avatar.tsx +106 -13
  128. package/src/components/badge.tsx +12 -11
  129. package/src/components/breadcrumb.tsx +5 -2
  130. package/src/components/button-group.stories.tsx +52 -0
  131. package/src/components/button-group.tsx +40 -0
  132. package/src/components/button.test.tsx +174 -0
  133. package/src/components/button.tsx +42 -19
  134. package/src/components/calendar.tsx +2 -2
  135. package/src/components/card.tsx +55 -13
  136. package/src/components/carousel.tsx +85 -13
  137. package/src/components/checkbox.test.tsx +58 -0
  138. package/src/components/checkbox.tsx +8 -3
  139. package/src/components/circular-progress.stories.tsx +39 -0
  140. package/src/components/circular-progress.tsx +102 -0
  141. package/src/components/collapsible.stories.tsx +7 -3
  142. package/src/components/combobox.stories.tsx +82 -0
  143. package/src/components/combobox.test.tsx +118 -0
  144. package/src/components/combobox.tsx +136 -0
  145. package/src/components/command.test.tsx +91 -0
  146. package/src/components/command.tsx +38 -15
  147. package/src/components/currency-input.test.tsx +63 -0
  148. package/src/components/currency-input.tsx +84 -66
  149. package/src/components/date-input.stories.tsx +31 -0
  150. package/src/components/date-input.tsx +43 -0
  151. package/src/components/description-list.stories.tsx +62 -0
  152. package/src/components/description-list.tsx +68 -0
  153. package/src/components/dialog.test.tsx +84 -0
  154. package/src/components/dialog.tsx +64 -24
  155. package/src/components/drawer.tsx +72 -23
  156. package/src/components/dropdown-menu.test.tsx +180 -0
  157. package/src/components/dropdown-menu.tsx +31 -6
  158. package/src/components/field.stories.tsx +36 -0
  159. package/src/components/field.test.tsx +157 -0
  160. package/src/components/field.tsx +146 -0
  161. package/src/components/form.stories.tsx +3 -2
  162. package/src/components/form.test.tsx +95 -0
  163. package/src/components/form.tsx +9 -0
  164. package/src/components/haptic-button.tsx +1 -1
  165. package/src/components/input-group.stories.tsx +61 -0
  166. package/src/components/input-group.tsx +99 -0
  167. package/src/components/input.test.tsx +103 -0
  168. package/src/components/input.tsx +24 -7
  169. package/src/components/item.stories.tsx +52 -0
  170. package/src/components/item.tsx +115 -0
  171. package/src/components/kbd.stories.tsx +33 -0
  172. package/src/components/kbd.tsx +46 -0
  173. package/src/components/lazy-markdown.tsx +10 -7
  174. package/src/components/multi-select-combobox.test.tsx +101 -0
  175. package/src/components/multi-select-combobox.tsx +85 -82
  176. package/src/components/multi-select-menu.tsx +49 -41
  177. package/src/components/native-select.stories.tsx +52 -0
  178. package/src/components/native-select.tsx +45 -0
  179. package/src/components/pagination.tsx +64 -5
  180. package/src/components/popover.tsx +66 -3
  181. package/src/components/progress.tsx +27 -4
  182. package/src/components/radio-group.test.tsx +64 -0
  183. package/src/components/radio-group.tsx +11 -3
  184. package/src/components/rating.test.tsx +112 -0
  185. package/src/components/rating.tsx +97 -82
  186. package/src/components/scroll-fade.stories.tsx +98 -0
  187. package/src/components/scroll-fade.tsx +127 -0
  188. package/src/components/search-bar.stories.tsx +1 -32
  189. package/src/components/search-bar.tsx +22 -59
  190. package/src/components/searchable-select.tsx +69 -73
  191. package/src/components/select.tsx +34 -19
  192. package/src/components/selection-bar.stories.tsx +37 -0
  193. package/src/components/selection-bar.tsx +65 -0
  194. package/src/components/separator.tsx +5 -2
  195. package/src/components/sheet.tsx +1 -1
  196. package/src/components/skeleton.tsx +2 -2
  197. package/src/components/slider.test.tsx +45 -0
  198. package/src/components/slider.tsx +31 -12
  199. package/src/components/sliding-panels.tsx +8 -5
  200. package/src/components/spinner.stories.tsx +35 -0
  201. package/src/components/spinner.tsx +50 -0
  202. package/src/components/stepper.stories.tsx +71 -0
  203. package/src/components/stepper.tsx +220 -0
  204. package/src/components/switch.test.tsx +52 -0
  205. package/src/components/switch.tsx +4 -3
  206. package/src/components/table.tsx +140 -35
  207. package/src/components/tabs.test.tsx +107 -0
  208. package/src/components/tabs.tsx +70 -23
  209. package/src/components/tag-group.stories.tsx +39 -0
  210. package/src/components/tag-group.tsx +28 -0
  211. package/src/components/tag.stories.tsx +50 -0
  212. package/src/components/tag.tsx +63 -0
  213. package/src/components/text.test.tsx +1 -1
  214. package/src/components/text.tsx +19 -3
  215. package/src/components/textarea.test.tsx +48 -0
  216. package/src/components/textarea.tsx +2 -1
  217. package/src/components/toast.tsx +4 -3
  218. package/src/components/toggle.tsx +2 -2
  219. package/src/components/tooltip.tsx +12 -2
  220. package/src/globals.css +126 -90
  221. package/dist/chunk-2BMJRXQX.js +0 -80
  222. package/dist/chunk-4AVS6CV4.js +0 -89
  223. package/dist/chunk-4UO73SKC.js +0 -26
  224. package/dist/chunk-CW24PLDW.js +0 -24
  225. package/dist/chunk-IQASCH2V.js +0 -25
  226. package/dist/chunk-NKHH23YK.js +0 -31
  227. 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 glass border border-border/50",
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 = ({ children, ...props }: CommandDialogProps) => {
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 shadow-2xl">
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/50 px-3" cmdk-input-wrapper="">
44
- <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
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-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
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 aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 transition-all duration-200 hover:bg-accent/50",
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
+ });