@nikala-ui/core 0.9.11 → 0.9.12

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.
@@ -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
  };
@@ -0,0 +1,192 @@
1
+ import { splitProps, type Component, type JSX, type ValidComponent } from "solid-js";
2
+ import * as ContextMenuPrimitive from "@kobalte/core/context-menu";
3
+ import type { PolymorphicProps } from "@kobalte/core/polymorphic";
4
+ import { createClickOutside } from "@nikala-ui/hooks";
5
+ import { ScrollArea } from "./scroll-area";
6
+ import { Separator } from "./separator";
7
+ import { Kbd } from "./kbd";
8
+ import { cn } from "@/lib/cn";
9
+
10
+ export type ContextMenuRootProps = ContextMenuPrimitive.ContextMenuRootProps;
11
+
12
+ export const ContextMenu: Component<ContextMenuRootProps> = (props) => {
13
+ return <ContextMenuPrimitive.Root {...props} />;
14
+ };
15
+
16
+ export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
17
+ export const ContextMenuGroup = ContextMenuPrimitive.Group;
18
+ export const ContextMenuSub = ContextMenuPrimitive.Sub;
19
+
20
+ export type ContextMenuSubTriggerProps<T extends ValidComponent = "div"> =
21
+ ContextMenuPrimitive.ContextMenuSubTriggerProps<T> & {
22
+ class?: string;
23
+ children?: JSX.Element;
24
+ inset?: boolean;
25
+ };
26
+
27
+ export const ContextMenuSubTrigger = <T extends ValidComponent = "div">(
28
+ props: PolymorphicProps<T, ContextMenuSubTriggerProps<T>>
29
+ ) => {
30
+ const [local, rest] = splitProps(props as ContextMenuSubTriggerProps, ["class", "children", "inset"]);
31
+
32
+ return (
33
+ <ContextMenuPrimitive.SubTrigger
34
+ class={cn(
35
+ "flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground text-foreground",
36
+ local.inset && "pl-8",
37
+ local.class
38
+ )}
39
+ {...(rest as any)}
40
+ >
41
+ {local.children}
42
+ <svg class="ml-auto h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
43
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
44
+ </svg>
45
+ </ContextMenuPrimitive.SubTrigger>
46
+ );
47
+ };
48
+
49
+ export type ContextMenuSubContentProps<T extends ValidComponent = "div"> =
50
+ ContextMenuPrimitive.ContextMenuSubContentProps<T> & {
51
+ class?: string;
52
+ };
53
+
54
+ export const ContextMenuSubContent = <T extends ValidComponent = "div">(
55
+ props: PolymorphicProps<T, ContextMenuSubContentProps<T>>
56
+ ) => {
57
+ const [local, rest] = splitProps(props as ContextMenuSubContentProps, ["class"]);
58
+
59
+ return (
60
+ <ContextMenuPrimitive.Portal>
61
+ <ContextMenuPrimitive.SubContent
62
+ class={cn(
63
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md transition-all animate-in fade-in-80 slide-in-from-top-1",
64
+ local.class
65
+ )}
66
+ {...(rest as any)}
67
+ />
68
+ </ContextMenuPrimitive.Portal>
69
+ );
70
+ };
71
+
72
+ export type ContextMenuContentProps<T extends ValidComponent = "div"> =
73
+ ContextMenuPrimitive.ContextMenuContentProps<T> & {
74
+ class?: string;
75
+ ref?: (el: HTMLElement) => void;
76
+ children?: JSX.Element;
77
+ };
78
+
79
+ export const ContextMenuContent = <T extends ValidComponent = "div">(
80
+ props: PolymorphicProps<T, ContextMenuContentProps<T>>
81
+ ) => {
82
+ const [local, rest] = splitProps(props as ContextMenuContentProps, ["class", "ref", "children"]);
83
+ let menuRef: HTMLElement | undefined;
84
+
85
+ createClickOutside({
86
+ target: () => menuRef,
87
+ onInteractOutside: () => {
88
+ // Automatic portal dismiss on outside click
89
+ },
90
+ });
91
+
92
+ return (
93
+ <ContextMenuPrimitive.Portal>
94
+ <ContextMenuPrimitive.Content
95
+ ref={(el) => {
96
+ menuRef = el;
97
+ if (typeof local.ref === "function") local.ref(el);
98
+ }}
99
+ class={cn(
100
+ "z-50 min-w-[8rem] rounded-md border border-border bg-popover text-popover-foreground shadow-md transition-all animate-in fade-in-80 slide-in-from-top-1 max-h-72 flex flex-col",
101
+ local.class
102
+ )}
103
+ {...(rest as any)}
104
+ >
105
+ <ScrollArea class="max-h-72 w-full rounded-[inherit]">
106
+ <div class="p-1">
107
+ {local.children}
108
+ </div>
109
+ </ScrollArea>
110
+ </ContextMenuPrimitive.Content>
111
+ </ContextMenuPrimitive.Portal>
112
+ );
113
+ };
114
+
115
+ export type ContextMenuItemProps<T extends ValidComponent = "div"> =
116
+ ContextMenuPrimitive.ContextMenuItemProps<T> & {
117
+ class?: string;
118
+ inset?: boolean;
119
+ };
120
+
121
+ export const ContextMenuItem = <T extends ValidComponent = "div">(
122
+ props: PolymorphicProps<T, ContextMenuItemProps<T>>
123
+ ) => {
124
+ const [local, rest] = splitProps(props as ContextMenuItemProps, ["class", "inset"]);
125
+
126
+ return (
127
+ <ContextMenuPrimitive.Item
128
+ class={cn(
129
+ "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-foreground",
130
+ local.inset && "pl-8",
131
+ local.class
132
+ )}
133
+ {...(rest as any)}
134
+ />
135
+ );
136
+ };
137
+
138
+ export type ContextMenuCheckboxItemProps<T extends ValidComponent = "div"> =
139
+ ContextMenuPrimitive.ContextMenuCheckboxItemProps<T> & {
140
+ class?: string;
141
+ children?: JSX.Element;
142
+ checked?: boolean;
143
+ };
144
+
145
+ export const ContextMenuCheckboxItem = <T extends ValidComponent = "div">(
146
+ props: PolymorphicProps<T, ContextMenuCheckboxItemProps<T>>
147
+ ) => {
148
+ const [local, rest] = splitProps(props as ContextMenuCheckboxItemProps, ["class", "children", "checked"]);
149
+
150
+ return (
151
+ <ContextMenuPrimitive.CheckboxItem
152
+ checked={local.checked}
153
+ class={cn(
154
+ "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-foreground",
155
+ local.class
156
+ )}
157
+ {...(rest as any)}
158
+ >
159
+ <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
160
+ <ContextMenuPrimitive.ItemIndicator>
161
+ <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
162
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
163
+ </svg>
164
+ </ContextMenuPrimitive.ItemIndicator>
165
+ </span>
166
+ {local.children}
167
+ </ContextMenuPrimitive.CheckboxItem>
168
+ );
169
+ };
170
+
171
+ export interface ContextMenuShortcutProps extends JSX.HTMLAttributes<HTMLSpanElement> {
172
+ class?: string;
173
+ children?: JSX.Element;
174
+ }
175
+
176
+ export const ContextMenuShortcut: Component<ContextMenuShortcutProps> = (props) => {
177
+ const [local, rest] = splitProps(props, ["class", "children"]);
178
+
179
+ return (
180
+ <span class={cn("ml-auto text-xs tracking-widest text-muted-foreground", local.class)} {...rest}>
181
+ <Kbd size="sm">{local.children}</Kbd>
182
+ </span>
183
+ );
184
+ };
185
+
186
+ export interface ContextMenuSeparatorProps {
187
+ class?: string;
188
+ }
189
+
190
+ export const ContextMenuSeparator: Component<ContextMenuSeparatorProps> = (props) => {
191
+ return <Separator class={cn("-mx-1 my-1 h-px bg-border", props.class)} />;
192
+ };