@nikala-ui/core 0.9.11 → 0.10.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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/registry/aspect-ratio.json +17 -0
  3. package/registry/button.json +4 -1
  4. package/registry/collapsible.json +18 -0
  5. package/registry/combobox.json +4 -1
  6. package/registry/command.json +3 -2
  7. package/registry/context-menu.json +24 -0
  8. package/registry/create-form.json +1 -1
  9. package/registry/dialog.json +4 -1
  10. package/registry/dropdown-menu.json +4 -1
  11. package/registry/empty.json +17 -0
  12. package/registry/field.json +20 -0
  13. package/registry/form-message.json +16 -0
  14. package/registry/form.json +17 -0
  15. package/registry/icon-button.json +20 -0
  16. package/registry/index.json +191 -1
  17. package/registry/number-input.json +24 -0
  18. package/registry/resizable.json +21 -0
  19. package/registry/scroll-area.json +21 -0
  20. package/registry/select.json +4 -1
  21. package/registry/sheet.json +4 -1
  22. package/registry/spinner.json +18 -0
  23. package/registry/status.json +18 -0
  24. package/registry/theme-manager.json +1 -1
  25. package/registry/toggle.json +18 -0
  26. package/src/registry/components/ui/aspect-ratio.tsx +31 -0
  27. package/src/registry/components/ui/button.tsx +18 -3
  28. package/src/registry/components/ui/collapsible.tsx +73 -0
  29. package/src/registry/components/ui/combobox.tsx +23 -84
  30. package/src/registry/components/ui/command.tsx +154 -66
  31. package/src/registry/components/ui/context-menu.tsx +192 -0
  32. package/src/registry/components/ui/dialog.tsx +39 -44
  33. package/src/registry/components/ui/dropdown-menu.tsx +40 -45
  34. package/src/registry/components/ui/empty.tsx +83 -0
  35. package/src/registry/components/ui/field.tsx +67 -0
  36. package/src/registry/components/ui/form-message.tsx +36 -0
  37. package/src/registry/components/ui/form.tsx +21 -0
  38. package/src/registry/components/ui/icon-button.tsx +34 -0
  39. package/src/registry/components/ui/number-input.tsx +150 -0
  40. package/src/registry/components/ui/resizable.tsx +193 -0
  41. package/src/registry/components/ui/scroll-area.tsx +218 -0
  42. package/src/registry/components/ui/select.tsx +22 -16
  43. package/src/registry/components/ui/sheet.tsx +62 -63
  44. package/src/registry/components/ui/spinner.tsx +41 -0
  45. package/src/registry/components/ui/status.tsx +119 -0
  46. package/src/registry/components/ui/theme-toggle.tsx +6 -4
  47. package/src/registry/components/ui/toggle.tsx +101 -0
  48. package/src/registry/metadata.ts +84 -2
@@ -0,0 +1,31 @@
1
+ import { splitProps, type Component, type JSX } from "solid-js";
2
+ import { cn } from "@/lib/cn";
3
+
4
+ export interface AspectRatioProps extends JSX.HTMLAttributes<HTMLDivElement> {
5
+ ratio?: number;
6
+ class?: string;
7
+ children?: JSX.Element;
8
+ }
9
+
10
+ /**
11
+ * Nikala UI AspectRatio Component.
12
+ * Displays content within a specific aspect ratio (e.g. 16/9, 4/3, 1/1) using CSS aspect-ratio.
13
+ */
14
+ export const AspectRatio: Component<AspectRatioProps> = (props) => {
15
+ const [local, rest] = splitProps(props, ["ratio", "class", "children", "style"]);
16
+
17
+ const computedRatio = () => (local.ratio !== undefined ? local.ratio : 16 / 9);
18
+
19
+ return (
20
+ <div
21
+ class={cn("relative w-full overflow-hidden", local.class)}
22
+ style={{
23
+ "aspect-ratio": `${computedRatio()}`,
24
+ ...(typeof local.style === "object" ? local.style : {}),
25
+ }}
26
+ {...rest}
27
+ >
28
+ {local.children}
29
+ </div>
30
+ );
31
+ };
@@ -1,6 +1,7 @@
1
- import { splitProps, type Component, type JSX } from "solid-js";
1
+ import { Show, splitProps, type Component, type JSX } from "solid-js";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { cn } from "@/lib/cn";
4
+ import { Spinner } from "./spinner";
4
5
 
5
6
  /**
6
7
  * Class variance authority configuration for button styling variants and sizes.
@@ -46,6 +47,8 @@ export const buttonVariants = cva(
46
47
  export interface ButtonProps
47
48
  extends JSX.ButtonHTMLAttributes<HTMLButtonElement>,
48
49
  VariantProps<typeof buttonVariants> {
50
+ /** Shows a loading spinner and prevents repeated clicks while active. */
51
+ loading?: boolean;
49
52
  class?: string;
50
53
  }
51
54
 
@@ -54,14 +57,26 @@ export interface ButtonProps
54
57
  */
55
58
  export const Button: Component<ButtonProps> = (props) => {
56
59
  // Use splitProps to preserve SolidJS reactivity for destructured props
57
- const [local, rest] = splitProps(props, ["variant", "size", "class", "children"]);
60
+ const [local, rest] = splitProps(props, [
61
+ "variant",
62
+ "size",
63
+ "class",
64
+ "children",
65
+ "loading",
66
+ "disabled",
67
+ ]);
58
68
 
59
69
  return (
60
70
  <button
61
71
  class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
72
+ disabled={local.disabled || local.loading}
73
+ aria-busy={local.loading ? "true" : undefined}
62
74
  {...rest}
63
75
  >
76
+ <Show when={local.loading}>
77
+ <Spinner size="sm" class="text-current" />
78
+ </Show>
64
79
  {local.children}
65
80
  </button>
66
81
  );
67
- };
82
+ };
@@ -0,0 +1,73 @@
1
+ import { splitProps, type JSX, type ValidComponent } from "solid-js";
2
+ import * as CollapsiblePrimitive from "@kobalte/core/collapsible";
3
+ import type { PolymorphicProps } from "@kobalte/core/polymorphic";
4
+ import { cn } from "@/lib/cn";
5
+
6
+ export type CollapsibleRootProps<T extends ValidComponent = "div"> =
7
+ CollapsiblePrimitive.CollapsibleRootProps<T> & {
8
+ class?: string;
9
+ };
10
+
11
+ /**
12
+ * Root Collapsible component built on Kobalte primitives.
13
+ */
14
+ export const Collapsible = <T extends ValidComponent = "div">(
15
+ props: PolymorphicProps<T, CollapsibleRootProps<T>>
16
+ ) => {
17
+ const [local, rest] = splitProps(props as CollapsibleRootProps, ["class"]);
18
+ return (
19
+ <CollapsiblePrimitive.Root
20
+ class={cn("w-full", local.class)}
21
+ {...(rest as any)}
22
+ />
23
+ );
24
+ };
25
+
26
+ export type CollapsibleTriggerProps<T extends ValidComponent = "button"> =
27
+ CollapsiblePrimitive.CollapsibleTriggerProps<T> & {
28
+ class?: string;
29
+ children?: JSX.Element;
30
+ };
31
+
32
+ /**
33
+ * Trigger element that toggles the Collapsible open/closed state.
34
+ */
35
+ export const CollapsibleTrigger = <T extends ValidComponent = "button">(
36
+ props: PolymorphicProps<T, CollapsibleTriggerProps<T>>
37
+ ) => {
38
+ const [local, rest] = splitProps(props as CollapsibleTriggerProps, ["class", "children"]);
39
+ return (
40
+ <CollapsiblePrimitive.Trigger
41
+ class={cn("flex w-full items-center justify-between cursor-pointer", local.class)}
42
+ {...rest}
43
+ >
44
+ {local.children}
45
+ </CollapsiblePrimitive.Trigger>
46
+ );
47
+ };
48
+
49
+ export type CollapsibleContentProps<T extends ValidComponent = "div"> =
50
+ CollapsiblePrimitive.CollapsibleContentProps<T> & {
51
+ class?: string;
52
+ children?: JSX.Element;
53
+ };
54
+
55
+ /**
56
+ * Collapsible content panel revealed when opened.
57
+ */
58
+ export const CollapsibleContent = <T extends ValidComponent = "div">(
59
+ props: PolymorphicProps<T, CollapsibleContentProps<T>>
60
+ ) => {
61
+ const [local, rest] = splitProps(props as CollapsibleContentProps, ["class", "children"]);
62
+ return (
63
+ <CollapsiblePrimitive.Content
64
+ class={cn(
65
+ "overflow-hidden transition-all data-expanded:animate-collapsible-down data-closed:animate-collapsible-up",
66
+ local.class
67
+ )}
68
+ {...rest}
69
+ >
70
+ {local.children}
71
+ </CollapsiblePrimitive.Content>
72
+ );
73
+ };
@@ -1,86 +1,44 @@
1
1
  import { splitProps, type JSX, type ValidComponent } from "solid-js";
2
+ import { Check, ChevronDown, X } from "lucide-solid";
3
+ import { ScrollArea } from "./scroll-area";
2
4
  import * as ComboboxPrimitive from "@kobalte/core/combobox";
3
5
  import { cn } from "@/lib/cn";
4
6
 
5
7
  export type ComboboxRootProps<Option = any, OptGroup = any, T extends ValidComponent = "div"> =
6
- ComboboxPrimitive.ComboboxRootProps<Option, OptGroup, T> & {
7
- class?: string;
8
- children?: JSX.Element;
9
- triggerMode?: "input" | "focus" | "both" | "manual";
10
- };
8
+ ComboboxPrimitive.ComboboxRootProps<Option, OptGroup, T>;
11
9
 
12
10
  /**
13
- * Root Combobox component providing search, single/multi-selection, and group support.
14
- * `triggerMode="focus"` or `triggerMode="both"` enables opening dropdown on input click/focus.
11
+ * Root Combobox primitive component wrapper.
15
12
  */
16
13
  export const Combobox = <Option = any, OptGroup = any, T extends ValidComponent = "div">(
17
14
  props: ComboboxRootProps<Option, OptGroup, T>
18
15
  ) => {
19
- const [local, rest] = splitProps(props as ComboboxRootProps, ["class", "children", "triggerMode"]);
20
-
21
- return (
22
- <ComboboxPrimitive.Root
23
- triggerMode={local.triggerMode ?? "input"}
24
- class={cn("relative w-full", local.class)}
25
- {...(rest as any)}
26
- >
27
- {local.children}
28
- </ComboboxPrimitive.Root>
29
- );
16
+ return <ComboboxPrimitive.Root {...props} />;
30
17
  };
31
18
 
32
19
  export type ComboboxControlProps<Option = any, T extends ValidComponent = "div"> =
33
20
  ComboboxPrimitive.ComboboxControlProps<Option, T> & {
34
21
  class?: string;
35
22
  children?: JSX.Element;
36
- clearable?: boolean;
37
- onClear?: () => void;
38
23
  };
39
24
 
40
25
  /**
41
- * Input container for Combobox supporting search input, selected tags, and clear button.
26
+ * Input container box supporting single or multi-select tokens.
42
27
  */
43
28
  export const ComboboxControl = <Option = any, T extends ValidComponent = "div">(
44
29
  props: ComboboxControlProps<Option, T>
45
30
  ) => {
46
- const [local, rest] = splitProps(props as ComboboxControlProps, [
47
- "class",
48
- "children",
49
- "clearable",
50
- "onClear",
51
- ]);
31
+ const [local, rest] = splitProps(props as ComboboxControlProps, ["class", "children"]);
52
32
 
53
33
  return (
54
34
  <ComboboxPrimitive.Control
55
35
  class={cn(
56
- "flex min-h-9 w-full flex-wrap items-center justify-between rounded-md border border-input bg-muted px-3 py-1 text-sm shadow-sm transition-colors focus-within:ring-1 focus-within:ring-primary focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 gap-1.5 text-foreground",
36
+ "flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-muted px-3 py-1.5 text-sm shadow-2xs ring-offset-background focus-within:ring-1 focus-within:ring-primary focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 text-foreground cursor-text transition-colors",
57
37
  local.class
58
38
  )}
59
39
  {...(rest as any)}
60
40
  >
61
- <div class="flex flex-wrap items-center gap-1.5 flex-1 min-w-0">
62
- {local.children}
63
- </div>
64
-
65
- <div class="flex items-center gap-1 shrink-0 self-center">
66
- {local.clearable && (
67
- <button
68
- type="button"
69
- tabIndex={-1}
70
- onClick={(e) => {
71
- e.stopPropagation();
72
- if (local.onClear) local.onClear();
73
- }}
74
- class="rounded-sm p-0.5 opacity-60 hover:opacity-100 hover:bg-accent text-foreground transition-opacity focus:outline-none cursor-pointer"
75
- aria-label="Clear selection"
76
- >
77
- <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
78
- <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
79
- </svg>
80
- </button>
81
- )}
82
- <ComboboxTrigger />
83
- </div>
41
+ {local.children}
84
42
  </ComboboxPrimitive.Control>
85
43
  );
86
44
  };
@@ -92,8 +50,7 @@ export type ComboboxInputProps<T extends ValidComponent = "input"> =
92
50
  };
93
51
 
94
52
  /**
95
- * Search input field embedded within ComboboxControl.
96
- * Supports `openOnFocus` to automatically trigger the dropdown when focused or clicked.
53
+ * Filter search input field.
97
54
  */
98
55
  export const ComboboxInput = <T extends ValidComponent = "input">(
99
56
  props: ComboboxInputProps<T>
@@ -178,12 +135,10 @@ export const ComboboxToken = <Option = any>(props: ComboboxTokenProps<Option>) =
178
135
  e.stopPropagation();
179
136
  if (local.onRemove) local.onRemove();
180
137
  }}
181
- class="rounded-xs p-0.5 hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground transition-colors focus:outline-none cursor-pointer"
182
- aria-label="Remove tag"
138
+ class="rounded-xs opacity-70 hover:opacity-100 focus:outline-none cursor-pointer text-muted-foreground hover:text-foreground"
183
139
  >
184
- <svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
185
- <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
186
- </svg>
140
+ <X class="h-3 w-3" />
141
+ <span class="sr-only">Remove</span>
187
142
  </button>
188
143
  </span>
189
144
  );
@@ -206,12 +161,14 @@ export const ComboboxContent = <T extends ValidComponent = "div">(
206
161
  <ComboboxPrimitive.Portal>
207
162
  <ComboboxPrimitive.Content
208
163
  class={cn(
209
- "relative z-50 min-w-8rem overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md animate-in fade-in-80 data-[expanded]:animate-in data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-[closed]:zoom-out-95 data-[expanded]:zoom-in-95",
164
+ "relative z-50 min-w-8rem overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md animate-in fade-in-80 data-expanded:animate-in data-closed:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-closed:zoom-out-95 data-expanded:zoom-in-95 max-h-60",
210
165
  local.class
211
166
  )}
212
167
  {...(rest as any)}
213
168
  >
214
- <ComboboxPrimitive.Listbox class="max-h-60 overflow-y-auto p-1 outline-none" />
169
+ <ScrollArea class="max-h-60 w-full">
170
+ <ComboboxPrimitive.Listbox class="p-1 outline-none" />
171
+ </ScrollArea>
215
172
  </ComboboxPrimitive.Content>
216
173
  </ComboboxPrimitive.Portal>
217
174
  );
@@ -234,19 +191,17 @@ export const ComboboxItem = <T extends ValidComponent = "li">(
234
191
  return (
235
192
  <ComboboxPrimitive.Item
236
193
  class={cn(
237
- "relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-accent data-highlighted:text-accent-foreground text-popover-foreground transition-colors",
194
+ "relative flex w-full cursor-pointer select-none items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50 text-foreground transition-colors",
238
195
  local.class
239
196
  )}
240
- {...rest}
197
+ {...(rest as any)}
241
198
  >
242
- <ComboboxPrimitive.ItemIndicator class="absolute left-2 flex h-4 w-4 items-center justify-center text-primary">
243
- <svg class="h-4 w-4 fill-none stroke-current stroke-2" viewBox="0 0 24 24">
244
- <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
245
- </svg>
246
- </ComboboxPrimitive.ItemIndicator>
247
- <ComboboxPrimitive.ItemLabel class="flex items-center gap-2 w-full truncate">
199
+ <ComboboxPrimitive.ItemLabel class="flex-1 truncate">
248
200
  {local.children}
249
201
  </ComboboxPrimitive.ItemLabel>
202
+ <ComboboxPrimitive.ItemIndicator class="ml-2 flex h-4 w-4 items-center justify-center text-primary">
203
+ <Check class="h-4 w-4 stroke-2" />
204
+ </ComboboxPrimitive.ItemIndicator>
250
205
  </ComboboxPrimitive.Item>
251
206
  );
252
207
  };
@@ -277,19 +232,3 @@ export const ComboboxGroup = <T extends ValidComponent = "li">(
277
232
  </ComboboxPrimitive.Section>
278
233
  );
279
234
  };
280
-
281
- /**
282
- * Empty state notice when no matching search items exist.
283
- */
284
- export const ComboboxEmpty = (props: { class?: string; children?: JSX.Element }) => {
285
- return (
286
- <div class={cn("py-6 text-center text-sm text-muted-foreground", props.class)}>
287
- {props.children || "No matching items found."}
288
- </div>
289
- );
290
- };
291
-
292
- /**
293
- * Hidden native select element for form integrations.
294
- */
295
- export const ComboboxHiddenSelect = ComboboxPrimitive.HiddenSelect;
@@ -10,17 +10,22 @@ import {
10
10
  type Accessor,
11
11
  } from "solid-js";
12
12
  import { createKeybindings } from "@nikala-ui/hooks";
13
- import { Dialog } from "@kobalte/core/dialog";
14
13
  import { Search, ArrowUp, ArrowDown, CornerDownLeft } from "lucide-solid";
15
14
  import { cn } from "@/lib/cn";
16
- import { Kbd, KbdGroup } from "../ui/kbd";
17
- import { InputGroup, InputGroupInput, InputGroupAddon } from "../ui/input-group";
18
- import { List, ListGroup, ListHeader, ListItem, type ListItemProps } from "../ui/list";
15
+ import { Kbd, KbdGroup } from "./kbd";
16
+ import { InputGroup, InputGroupInput, InputGroupAddon } from "./input-group";
17
+ import { List, ListGroup, ListHeader, ListItem, type ListItemProps } from "./list";
18
+ import { Dialog, DialogOverlay, DialogContent } from "./dialog";
19
+ import { ScrollArea } from "./scroll-area";
19
20
 
20
21
  /* --- Command Context State --- */
21
22
  interface CommandContextValue {
22
23
  search: Accessor<string>;
23
24
  setSearch: (value: string) => void;
25
+ activeIndex: Accessor<number>;
26
+ setActiveIndex: (fn: (prev: number) => number) => void;
27
+ registerItemIndex: () => number;
28
+ onItemSelect: (fn: () => void) => void;
24
29
  }
25
30
 
26
31
  const CommandContext = createContext<CommandContextValue>();
@@ -28,30 +33,78 @@ const CommandContext = createContext<CommandContextValue>();
28
33
  export const useCommand = () => {
29
34
  const ctx = useContext(CommandContext);
30
35
  if (!ctx) {
31
- throw new Error("useCommand must be used within a <Command />");
36
+ throw new Error("useCommand must be used within a <Command /> root component.");
32
37
  }
33
38
  return ctx;
34
39
  };
35
40
 
36
- /* --- Root Command Container --- */
37
- export interface CommandProps extends JSX.HTMLAttributes<HTMLDivElement> {
41
+ /* --- Command Root --- */
42
+ export interface CommandProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "children"> {
38
43
  class?: string;
44
+ children?: JSX.Element | ((ctx: CommandContextValue) => JSX.Element);
39
45
  }
40
46
 
41
47
  export const Command: Component<CommandProps> = (props) => {
42
48
  const [local, rest] = splitProps(props, ["class", "children"]);
43
49
  const [search, setSearch] = createSignal("");
50
+ const [activeIndex, setActiveIndex] = createSignal(0);
51
+ let itemCounter = 0;
52
+ const itemCallbacks: Record<number, () => void> = {};
53
+ let containerRef: HTMLDivElement | undefined;
54
+
55
+ const registerItemIndex = () => {
56
+ const idx = itemCounter;
57
+ itemCounter += 1;
58
+ return idx;
59
+ };
60
+
61
+ const onItemSelect = (fn: () => void) => {
62
+ itemCallbacks[activeIndex()] = fn;
63
+ };
64
+
65
+ const handleKeyDown = (e: KeyboardEvent) => {
66
+ if (e.key === "ArrowDown") {
67
+ e.preventDefault();
68
+ setActiveIndex((prev) => Math.min(prev + 1, Math.max(0, itemCounter - 1)));
69
+ } else if (e.key === "ArrowUp") {
70
+ e.preventDefault();
71
+ setActiveIndex((prev) => Math.max(prev - 1, 0));
72
+ } else if (e.key === "Enter") {
73
+ e.preventDefault();
74
+ const fn = itemCallbacks[activeIndex()];
75
+ if (fn) fn();
76
+ const current = containerRef?.querySelector('[data-command-active="true"]') as HTMLElement;
77
+ if (current) {
78
+ current.click();
79
+ }
80
+ }
81
+ };
82
+
83
+ const ctx: CommandContextValue = {
84
+ search,
85
+ setSearch: (val) => {
86
+ setSearch(val);
87
+ setActiveIndex(0);
88
+ },
89
+ activeIndex,
90
+ setActiveIndex: (fn) => setActiveIndex(fn),
91
+ registerItemIndex,
92
+ onItemSelect,
93
+ };
44
94
 
45
95
  return (
46
- <CommandContext.Provider value={{ search, setSearch }}>
96
+ <CommandContext.Provider value={ctx}>
47
97
  <div
98
+ ref={containerRef}
99
+ onKeyDown={handleKeyDown}
48
100
  class={cn(
49
- "flex flex-col w-full h-full rounded-lg bg-popover text-popover-foreground overflow-hidden border border-border shadow-md",
101
+ "flex flex-col w-full h-full rounded-lg bg-popover text-popover-foreground overflow-hidden border border-border shadow-md outline-none",
50
102
  local.class
51
103
  )}
104
+ tabIndex={0}
52
105
  {...rest}
53
106
  >
54
- {local.children}
107
+ {typeof local.children === "function" ? (local.children as any)(ctx) : local.children}
55
108
  </div>
56
109
  </CommandContext.Provider>
57
110
  );
@@ -62,7 +115,7 @@ export interface CommandDialogProps {
62
115
  open?: boolean;
63
116
  onOpenChange?: (open: boolean) => void;
64
117
  enableHotkey?: boolean;
65
- children?: JSX.Element;
118
+ children?: JSX.Element | ((ctx: CommandContextValue) => JSX.Element);
66
119
  class?: string;
67
120
  }
68
121
 
@@ -91,14 +144,12 @@ export const CommandDialog: Component<CommandDialogProps> = (props) => {
91
144
 
92
145
  return (
93
146
  <Dialog open={isOpen()} onOpenChange={setOpen}>
94
- <Dialog.Portal>
95
- <Dialog.Overlay class="fixed inset-0 z-50 bg-black/60 backdrop-blur-xs data-expanded:animate-in data-closed:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0" />
96
- <div class="fixed inset-0 z-50 flex items-start justify-center pt-16 sm:pt-24 px-4">
97
- <Dialog.Content class="w-full max-w-xl rounded-lg border border-border bg-popover text-popover-foreground shadow-2xl outline-none data-expanded:animate-in data-closed:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-closed:zoom-out-95 data-expanded:zoom-in-95">
98
- <Command class={props.class}>{props.children}</Command>
99
- </Dialog.Content>
100
- </div>
101
- </Dialog.Portal>
147
+ <DialogOverlay class="fixed inset-0 z-50 bg-black/60 backdrop-blur-xs data-expanded:animate-in data-closed:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0" />
148
+ <div class="fixed inset-0 z-50 flex items-start justify-center pt-16 sm:pt-24 px-4">
149
+ <DialogContent class="w-full max-w-xl rounded-lg border border-border bg-popover text-popover-foreground shadow-2xl outline-none data-expanded:animate-in data-closed:animate-out data-[closed]:fade-out-0 data-[expanded]:fade-in-0 data-closed:zoom-out-95 data-expanded:zoom-in-95 p-0">
150
+ <Command class={props.class}>{props.children}</Command>
151
+ </DialogContent>
152
+ </div>
102
153
  </Dialog>
103
154
  );
104
155
  };
@@ -120,6 +171,7 @@ export const CommandInput: Component<CommandInputProps> = (props) => {
120
171
  </InputGroupAddon>
121
172
 
122
173
  <InputGroupInput
174
+ ref={(el) => setTimeout(() => el.focus(), 50)}
123
175
  value={search()}
124
176
  onInput={(e) => {
125
177
  setSearch(e.currentTarget.value);
@@ -144,12 +196,12 @@ export const CommandList: Component<CommandListProps> = (props) => {
144
196
  const [local, rest] = splitProps(props, ["class", "children"]);
145
197
 
146
198
  return (
147
- <div
148
- class={cn("max-h-82.5 overflow-y-auto p-1.5 scrollbar-thin", local.class)}
199
+ <ScrollArea
200
+ class={cn("max-h-82.5 p-1.5", local.class)}
149
201
  {...rest}
150
202
  >
151
203
  <List>{local.children}</List>
152
- </div>
204
+ </ScrollArea>
153
205
  );
154
206
  };
155
207
 
@@ -200,87 +252,123 @@ export interface CommandItemProps extends ListItemProps {
200
252
  }
201
253
 
202
254
  export const CommandItem: Component<CommandItemProps> = (props) => {
255
+ let itemRef: HTMLDivElement | undefined;
203
256
  const [local, rest] = splitProps(props, [
204
257
  "title",
205
258
  "subtitle",
206
259
  "keywords",
260
+ "disabled",
207
261
  "onSelect",
208
- "onClick",
209
262
  "class",
263
+ "children",
210
264
  ]);
211
- const { search } = useCommand();
212
265
 
213
- /* Auto fuzzy-filter item based on search query */
214
- const matchesSearch = () => {
215
- const query = search().toLowerCase().trim();
216
- if (!query) return true;
266
+ const ctx = useCommand();
267
+ const index = ctx.registerItemIndex();
217
268
 
218
- const titleMatch = local.title?.toLowerCase().includes(query);
219
- const subtitleMatch = local.subtitle?.toLowerCase().includes(query);
220
- const keywordMatch = local.keywords?.some((k) =>
221
- k.toLowerCase().includes(query)
222
- );
269
+ const isActive = () => ctx.activeIndex() === index;
223
270
 
224
- return Boolean(titleMatch || subtitleMatch || keywordMatch);
271
+ /* Automatic scroll into view when navigating with Arrow keys */
272
+ const scrollIntoViewIfNeeded = () => {
273
+ if (isActive() && itemRef) {
274
+ itemRef.scrollIntoView({ block: "nearest", behavior: "smooth" });
275
+ }
225
276
  };
226
277
 
227
- const handleClick = (e: MouseEvent) => {
228
- if (typeof local.onSelect === "function") {
229
- local.onSelect();
230
- }
231
- if (typeof local.onClick === "function") {
232
- local.onClick(e as any);
278
+ const isVisible = () => {
279
+ const query = ctx.search().toLowerCase().trim();
280
+ if (!query) return true;
281
+
282
+ const titleStr = typeof local.title === "string" ? local.title.toLowerCase() : "";
283
+ const subStr = typeof local.subtitle === "string" ? local.subtitle.toLowerCase() : "";
284
+
285
+ if (titleStr.includes(query) || subStr.includes(query)) return true;
286
+
287
+ if (local.keywords) {
288
+ return local.keywords.some((k) => k.toLowerCase().includes(query));
233
289
  }
290
+
291
+ return false;
292
+ };
293
+
294
+ const handleSelect = () => {
295
+ if (local.disabled) return;
296
+ if (local.onSelect) local.onSelect();
234
297
  };
235
298
 
236
299
  return (
237
- <Show when={matchesSearch()}>
300
+ <Show when={isVisible()}>
238
301
  <ListItem
302
+ ref={(el) => {
303
+ itemRef = el;
304
+ scrollIntoViewIfNeeded();
305
+ }}
239
306
  title={local.title}
240
307
  subtitle={local.subtitle}
241
- onClick={handleClick}
242
- class={local.class}
308
+ data-command-active={isActive() ? "true" : "false"}
309
+ class={cn(
310
+ "relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors",
311
+ isActive()
312
+ ? "bg-accent text-accent-foreground font-medium"
313
+ : "text-popover-foreground hover:bg-muted/50",
314
+ local.disabled && "pointer-events-none opacity-50",
315
+ local.class
316
+ )}
317
+ onClick={handleSelect}
318
+ onMouseEnter={() => ctx.setActiveIndex(() => index)}
243
319
  {...rest}
244
- />
320
+ >
321
+ {local.children}
322
+ </ListItem>
245
323
  </Show>
246
324
  );
247
325
  };
248
326
 
249
- /* --- Command Footer --- */
327
+ /* --- Command Footer Indicator Bar --- */
250
328
  export interface CommandFooterProps extends JSX.HTMLAttributes<HTMLDivElement> {
251
329
  class?: string;
252
330
  }
253
331
 
254
332
  export const CommandFooter: Component<CommandFooterProps> = (props) => {
255
- const [local, rest] = splitProps(props, ["class"]);
333
+ const [local, rest] = splitProps(props, ["class", "children"]);
256
334
 
257
335
  return (
258
336
  <div
259
337
  class={cn(
260
- "flex items-center justify-between border-t border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground select-none",
338
+ "flex items-center justify-between border-t border-border px-3 py-2 text-xs text-muted-foreground bg-muted/40",
261
339
  local.class
262
340
  )}
263
341
  {...rest}
264
342
  >
265
- <div class="flex items-center gap-3">
266
- <span class="flex items-center gap-1">
267
- <KbdGroup>
268
- <Kbd size="sm"><ArrowUp class="w-2.5 h-2.5" /></Kbd>
269
- <Kbd size="sm"><ArrowDown class="w-2.5 h-2.5" /></Kbd>
270
- </KbdGroup>
271
- <span>Navigate</span>
272
- </span>
273
-
274
- <span class="flex items-center gap-1">
275
- <Kbd size="sm"><CornerDownLeft class="w-2.5 h-2.5" /></Kbd>
276
- <span>Select</span>
277
- </span>
278
- </div>
279
-
280
- <span class="flex items-center gap-1">
281
- <Kbd size="sm">Esc</Kbd>
282
- <span>Close</span>
283
- </span>
343
+ {local.children || (
344
+ <>
345
+ <div class="flex items-center gap-2">
346
+ <span class="inline-flex items-center gap-1">
347
+ <KbdGroup>
348
+ <Kbd size="sm">
349
+ <ArrowUp class="w-2.5 h-2.5" />
350
+ </Kbd>
351
+ <Kbd size="sm">
352
+ <ArrowDown class="w-2.5 h-2.5" />
353
+ </Kbd>
354
+ </KbdGroup>
355
+ <span>Navigate</span>
356
+ </span>
357
+
358
+ <span class="inline-flex items-center gap-1">
359
+ <Kbd size="sm">
360
+ <CornerDownLeft class="w-2.5 h-2.5" />
361
+ </Kbd>
362
+ <span>Select</span>
363
+ </span>
364
+ </div>
365
+
366
+ <span class="inline-flex items-center gap-1">
367
+ <Kbd size="sm">ESC</Kbd>
368
+ <span>Close</span>
369
+ </span>
370
+ </>
371
+ )}
284
372
  </div>
285
373
  );
286
374
  };