@nikala-ui/core 0.11.0 → 0.12.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 (109) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +97 -20
  3. package/package.json +43 -2
  4. package/registry/api-table.json +21 -0
  5. package/registry/banner.json +1 -1
  6. package/registry/button.json +1 -1
  7. package/registry/callout.json +19 -0
  8. package/registry/checkbox.json +4 -1
  9. package/registry/code-block.json +26 -0
  10. package/registry/code-group.json +20 -0
  11. package/registry/combobox.json +1 -1
  12. package/registry/command.json +3 -2
  13. package/registry/component-viewer.json +25 -0
  14. package/registry/container.json +18 -0
  15. package/registry/context-menu.json +1 -1
  16. package/registry/create-app-updater.json +13 -0
  17. package/registry/create-document-tabs.json +13 -0
  18. package/registry/create-global-shortcut.json +13 -0
  19. package/registry/create-tauri-window.json +13 -0
  20. package/registry/create-tiptap-editor.json +34 -0
  21. package/registry/dialog.json +1 -1
  22. package/registry/dropdown-menu.json +3 -2
  23. package/registry/field.json +1 -1
  24. package/registry/file-tree.json +21 -0
  25. package/registry/footer.json +1 -1
  26. package/registry/form-message.json +3 -2
  27. package/registry/icon-button.json +1 -1
  28. package/registry/index.json +336 -5
  29. package/registry/navbar.json +1 -1
  30. package/registry/navigation-menu.json +1 -1
  31. package/registry/number-input.json +1 -1
  32. package/registry/package-manager-tabs.json +24 -0
  33. package/registry/pager.json +21 -0
  34. package/registry/popover.json +4 -1
  35. package/registry/resizable.json +1 -1
  36. package/registry/rich-text-editor.json +95 -0
  37. package/registry/scroll-area.json +1 -1
  38. package/registry/section-heading.json +21 -0
  39. package/registry/select.json +3 -2
  40. package/registry/sheet.json +1 -1
  41. package/registry/sidebar.json +1 -1
  42. package/registry/status.json +1 -1
  43. package/registry/steps.json +20 -0
  44. package/registry/switch.json +4 -1
  45. package/registry/table-of-contents.json +23 -0
  46. package/registry/tabs.json +4 -1
  47. package/registry/theme-manager.json +6 -5
  48. package/registry/titlebar-tabs.json +24 -0
  49. package/registry/titlebar.json +26 -0
  50. package/registry/toggle-group.json +1 -1
  51. package/registry/updater-modal.json +26 -0
  52. package/src/index.ts +48 -0
  53. package/src/registry/components/ui/api-table.tsx +117 -0
  54. package/src/registry/components/ui/banner.tsx +0 -2
  55. package/src/registry/components/ui/button.tsx +1 -1
  56. package/src/registry/components/ui/callout.tsx +137 -0
  57. package/src/registry/components/ui/checkbox.tsx +1 -1
  58. package/src/registry/components/ui/code-block.tsx +326 -0
  59. package/src/registry/components/ui/code-group.tsx +105 -0
  60. package/src/registry/components/ui/combobox.tsx +83 -18
  61. package/src/registry/components/ui/command.tsx +110 -106
  62. package/src/registry/components/ui/component-viewer.tsx +363 -0
  63. package/src/registry/components/ui/container.tsx +45 -0
  64. package/src/registry/components/ui/context-menu.tsx +67 -22
  65. package/src/registry/components/ui/dialog.tsx +42 -32
  66. package/src/registry/components/ui/dropdown-menu.tsx +69 -31
  67. package/src/registry/components/ui/field.tsx +7 -3
  68. package/src/registry/components/ui/file-tree.tsx +181 -0
  69. package/src/registry/components/ui/footer.tsx +1 -1
  70. package/src/registry/components/ui/form-message.tsx +2 -2
  71. package/src/registry/components/ui/icon-button.tsx +1 -1
  72. package/src/registry/components/ui/navbar.tsx +18 -11
  73. package/src/registry/components/ui/navigation-menu.tsx +2 -2
  74. package/src/registry/components/ui/number-input.tsx +3 -3
  75. package/src/registry/components/ui/package-manager-tabs.tsx +226 -0
  76. package/src/registry/components/ui/pager.tsx +102 -0
  77. package/src/registry/components/ui/popover.tsx +1 -1
  78. package/src/registry/components/ui/resizable.tsx +3 -1
  79. package/src/registry/components/ui/rich-text-editor/bubble-menu.tsx +236 -0
  80. package/src/registry/components/ui/rich-text-editor/editor.tsx +308 -0
  81. package/src/registry/components/ui/rich-text-editor/extensions.ts +116 -0
  82. package/src/registry/components/ui/rich-text-editor/footer.tsx +36 -0
  83. package/src/registry/components/ui/rich-text-editor/image-dialog.tsx +75 -0
  84. package/src/registry/components/ui/rich-text-editor/index.ts +11 -0
  85. package/src/registry/components/ui/rich-text-editor/link-dialog.tsx +48 -0
  86. package/src/registry/components/ui/rich-text-editor/markdown.ts +170 -0
  87. package/src/registry/components/ui/rich-text-editor/slash-command.tsx +301 -0
  88. package/src/registry/components/ui/rich-text-editor/table-controls.tsx +68 -0
  89. package/src/registry/components/ui/rich-text-editor/toolbar.tsx +465 -0
  90. package/src/registry/components/ui/rich-text-editor/use-editor.ts +231 -0
  91. package/src/registry/components/ui/scroll-area.tsx +12 -2
  92. package/src/registry/components/ui/section-heading.tsx +108 -0
  93. package/src/registry/components/ui/select.tsx +16 -19
  94. package/src/registry/components/ui/sheet.tsx +58 -53
  95. package/src/registry/components/ui/sidebar.tsx +4 -4
  96. package/src/registry/components/ui/status.tsx +7 -5
  97. package/src/registry/components/ui/steps.tsx +198 -0
  98. package/src/registry/components/ui/switch.tsx +1 -1
  99. package/src/registry/components/ui/table-of-contents.tsx +131 -0
  100. package/src/registry/components/ui/tabs.tsx +1 -1
  101. package/src/registry/components/ui/theme-toggle.tsx +175 -139
  102. package/src/registry/components/ui/titlebar-tabs.tsx +773 -0
  103. package/src/registry/components/ui/titlebar.tsx +468 -0
  104. package/src/registry/components/ui/toggle-group.tsx +1 -1
  105. package/src/registry/components/ui/updater-modal.tsx +254 -0
  106. package/src/registry/metadata.ts +167 -6
  107. package/src/registry/providers/theme-provider.tsx +16 -8
  108. package/src/registry/providers/theme-script.tsx +29 -9
  109. package/src/registry/providers/theme-transitions.ts +81 -50
@@ -0,0 +1,326 @@
1
+ import {
2
+ createSignal,
3
+ createEffect,
4
+ createMemo,
5
+ splitProps,
6
+ For,
7
+ Show,
8
+ type JSX,
9
+ type ParentComponent,
10
+ } from "solid-js";
11
+ import { Check, Copy } from "lucide-solid";
12
+ import { Button } from "@/components/ui/button";
13
+ import { Badge } from "@/components/ui/badge";
14
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
15
+ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
16
+ import { createClipboard } from "@/hooks/create-clipboard";
17
+ import { cn } from "@/lib/cn";
18
+
19
+ export type CodeBlockPackageManager = "bunx" | "npx" | "pnpm" | "yarn";
20
+
21
+ export interface CodeBlockProps extends JSX.HTMLAttributes<HTMLDivElement> {
22
+ /** The raw source code text to display and copy */
23
+ code?: string;
24
+ /** File name to display in the header bar (e.g. "App.tsx", "main.rs") */
25
+ filename?: string;
26
+ /** Programming language badge (e.g. "tsx", "rust", "bash") */
27
+ language?: string;
28
+ /** Alias for language */
29
+ lang?: string;
30
+ /** Whether the code snippet is an executable CLI command */
31
+ isCli?: boolean;
32
+ /** Shows the interactive package manager runner tabs (bunx, npx, pnpm, yarn) */
33
+ showPmSwitcher?: boolean;
34
+ /** List of package manager runners to show */
35
+ managers?: CodeBlockPackageManager[];
36
+ /** Whether the copy button is enabled. Defaults to true. */
37
+ copyable?: boolean;
38
+ class?: string;
39
+ }
40
+
41
+ let shikiHighlighterPromise: Promise<any> | null = null;
42
+
43
+ async function getShikiHighlighter() {
44
+ if (typeof window === "undefined") return null;
45
+ if (!shikiHighlighterPromise) {
46
+ try {
47
+ const { createHighlighter } = await import("shiki");
48
+ shikiHighlighterPromise = createHighlighter({
49
+ themes: ["github-dark", "github-light"],
50
+ langs: [
51
+ "typescript",
52
+ "javascript",
53
+ "tsx",
54
+ "jsx",
55
+ "bash",
56
+ "json",
57
+ "css",
58
+ "html",
59
+ "rust",
60
+ ],
61
+ });
62
+ } catch (e) {
63
+ console.warn("Failed to load Shiki highlighter", e);
64
+ return null;
65
+ }
66
+ }
67
+ return shikiHighlighterPromise;
68
+ }
69
+
70
+ function highlightCliCommand(code: string): string {
71
+ const trimmed = code.trim();
72
+ const parts = trimmed.split(/\s+/);
73
+ if (parts.length === 0) return code;
74
+
75
+ const isRunner = ["bunx", "npx", "pnpm", "yarn", "bun", "npm", "cargo", "deno"].includes(parts[0]);
76
+ if (!isRunner) return `<span class="text-foreground">${escapeHtml(trimmed)}</span>`;
77
+
78
+ return parts
79
+ .map((part, index) => {
80
+ if (index === 0) {
81
+ return `<span class="text-amber-500 dark:text-amber-400 font-bold">${escapeHtml(part)}</span>`;
82
+ }
83
+ if (part.startsWith("-")) {
84
+ return `<span class="text-purple-400 dark:text-purple-300">${escapeHtml(part)}</span>`;
85
+ }
86
+ if (part.startsWith("@")) {
87
+ return `<span class="text-emerald-400 dark:text-emerald-300 font-medium">${escapeHtml(part)}</span>`;
88
+ }
89
+ if (index === 1 && ["add", "install", "init", "create", "run"].includes(part)) {
90
+ return `<span class="text-sky-500 dark:text-sky-400 font-medium">${escapeHtml(part)}</span>`;
91
+ }
92
+ return `<span class="text-foreground/90">${escapeHtml(part)}</span>`;
93
+ })
94
+ .join(" ");
95
+ }
96
+
97
+ function transformCommandForPm(cmd: string, pm: CodeBlockPackageManager): string {
98
+ const trimmed = cmd.trim();
99
+ const runners = ["bunx", "npx", "pnpm dlx", "pnpm", "yarn dlx", "yarn", "bun", "npm"];
100
+
101
+ let base = trimmed;
102
+ for (const r of runners) {
103
+ if (trimmed.startsWith(r)) {
104
+ base = trimmed.slice(r.length).trim();
105
+ break;
106
+ }
107
+ }
108
+
109
+ switch (pm) {
110
+ case "bunx":
111
+ return `bunx ${base}`;
112
+ case "npx":
113
+ return `npx ${base}`;
114
+ case "pnpm":
115
+ return `pnpm dlx ${base}`;
116
+ case "yarn":
117
+ return `yarn dlx ${base}`;
118
+ default:
119
+ return `${pm} ${base}`;
120
+ }
121
+ }
122
+
123
+ function escapeHtml(str: string): string {
124
+ return str
125
+ .replace(/&/g, "&amp;")
126
+ .replace(/</g, "&lt;")
127
+ .replace(/>/g, "&gt;")
128
+ .replace(/"/g, "&quot;")
129
+ .replace(/'/g, "&#039;");
130
+ }
131
+
132
+ export const CodeBlock: ParentComponent<CodeBlockProps> = (props) => {
133
+ const [local, rest] = splitProps(props, [
134
+ "id",
135
+ "code",
136
+ "filename",
137
+ "language",
138
+ "lang",
139
+ "isCli",
140
+ "showPmSwitcher",
141
+ "managers",
142
+ "copyable",
143
+ "class",
144
+ "children",
145
+ ]);
146
+
147
+ const clipboard = createClipboard();
148
+ const [activePm, setActivePm] = createSignal<CodeBlockPackageManager>("bunx");
149
+ const [highlightedHtml, setHighlightedHtml] = createSignal("");
150
+
151
+ const effectiveLang = () => (local.lang || local.language || "tsx").toLowerCase();
152
+ const copyable = () => local.copyable ?? true;
153
+ const defaultManagers: CodeBlockPackageManager[] = ["bunx", "npx", "pnpm", "yarn"];
154
+ const managersList = () => local.managers || defaultManagers;
155
+
156
+ const isBashCli = () => {
157
+ const l = effectiveLang();
158
+ return l === "bash" || l === "sh" || l === "shell" || Boolean(local.isCli);
159
+ };
160
+
161
+ const cleanCode = createMemo(() => {
162
+ const raw = (local.code || "").trim();
163
+ if (local.isCli || local.showPmSwitcher) {
164
+ return transformCommandForPm(raw, activePm());
165
+ }
166
+ return raw;
167
+ });
168
+
169
+ createEffect(() => {
170
+ const codeStr = cleanCode();
171
+ const lang = effectiveLang();
172
+
173
+ if (isBashCli()) {
174
+ setHighlightedHtml(highlightCliCommand(codeStr));
175
+ return;
176
+ }
177
+
178
+ if (!codeStr) {
179
+ setHighlightedHtml("");
180
+ return;
181
+ }
182
+
183
+ // Attempt Shiki highlighting
184
+ getShikiHighlighter().then((highlighter) => {
185
+ if (highlighter) {
186
+ try {
187
+ const html = highlighter.codeToHtml(codeStr, {
188
+ lang: lang === "js" ? "javascript" : lang === "ts" ? "typescript" : lang,
189
+ themes: {
190
+ light: "github-light",
191
+ dark: "github-dark",
192
+ },
193
+ });
194
+ setHighlightedHtml(html);
195
+ return;
196
+ } catch (err) {
197
+ console.warn(`Shiki failed for language: ${lang}`, err);
198
+ }
199
+ }
200
+ // Fallback
201
+ setHighlightedHtml(`<pre class="text-foreground/90"><code>${escapeHtml(codeStr)}</code></pre>`);
202
+ });
203
+ });
204
+
205
+ const handleCopy = async () => {
206
+ let textToCopy = cleanCode();
207
+ if (!textToCopy && typeof window !== "undefined") {
208
+ const target = document.querySelector(`[data-code-block-id="${local.id}"]`);
209
+ if (target) textToCopy = target.textContent || "";
210
+ }
211
+
212
+ if (!textToCopy) return;
213
+ await clipboard.copy(textToCopy);
214
+ };
215
+
216
+ const shouldShowSwitcher = () => Boolean(local.showPmSwitcher || local.isCli);
217
+ const hasHeader = () => Boolean(shouldShowSwitcher() || local.filename || effectiveLang());
218
+
219
+ return (
220
+ <div
221
+ class={cn(
222
+ "group relative my-4 w-full overflow-hidden rounded-lg border border-border bg-muted/40 font-mono text-sm shadow-2xs",
223
+ local.class
224
+ )}
225
+ {...rest}
226
+ >
227
+ {/* Code Header Bar */}
228
+ <Show when={hasHeader()}>
229
+ <div class="flex h-9 items-center justify-between border-b border-border/70 bg-muted/70 px-3.5 text-xs text-muted-foreground select-none">
230
+ <div class="flex items-center gap-2">
231
+ <Show when={shouldShowSwitcher()}>
232
+ <Tabs
233
+ value={activePm()}
234
+ onChange={(val) => setActivePm(val as CodeBlockPackageManager)}
235
+ class="w-auto flex-none"
236
+ >
237
+ <TabsList class="w-auto inline-flex h-auto bg-background/60 p-0.5 border border-border/50 gap-0.5 rounded-md font-mono">
238
+ <For each={managersList()}>
239
+ {(pm) => (
240
+ <TabsTrigger
241
+ value={pm}
242
+ class="rounded-xs px-2 py-0.5 text-[11px] font-mono transition-colors cursor-pointer data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:font-semibold data-[state=active]:shadow-2xs"
243
+ >
244
+ {pm}
245
+ </TabsTrigger>
246
+ )}
247
+ </For>
248
+ </TabsList>
249
+ </Tabs>
250
+ </Show>
251
+
252
+ <Show when={local.filename}>
253
+ <span class="font-medium text-foreground tracking-tight">{local.filename}</span>
254
+ </Show>
255
+
256
+ <Show when={effectiveLang() && !shouldShowSwitcher() && !local.filename}>
257
+ <Badge variant="outline" class="uppercase text-[10px] px-1.5 py-0 h-4 font-mono font-semibold">
258
+ {effectiveLang()}
259
+ </Badge>
260
+ </Show>
261
+ </div>
262
+
263
+ <Show when={copyable() && (local.code || cleanCode())}>
264
+ <Tooltip>
265
+ <TooltipTrigger
266
+ as={Button}
267
+ variant="ghost"
268
+ size="icon"
269
+ onClick={handleCopy}
270
+ aria-label={clipboard.copied() ? "Copied code" : "Copy code"}
271
+ class="size-6 p-0 rounded-xs text-muted-foreground hover:bg-background hover:text-foreground cursor-pointer"
272
+ >
273
+ <Show when={clipboard.copied()} fallback={<Copy class="size-3.5" />}>
274
+ <Check class="size-3.5 text-emerald-500 dark:text-emerald-400" />
275
+ </Show>
276
+ </TooltipTrigger>
277
+ <TooltipContent class="text-[10px] py-1 px-2">
278
+ {clipboard.copied() ? "Copied!" : "Copy code"}
279
+ </TooltipContent>
280
+ </Tooltip>
281
+ </Show>
282
+ </div>
283
+ </Show>
284
+
285
+ {/* Floating Copy Button (if no header bar is present) */}
286
+ <Show when={!hasHeader() && copyable() && (local.code || cleanCode())}>
287
+ <div class="absolute right-2.5 top-2.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
288
+ <Tooltip>
289
+ <TooltipTrigger
290
+ as={Button}
291
+ variant="outline"
292
+ size="icon"
293
+ onClick={handleCopy}
294
+ aria-label={clipboard.copied() ? "Copied code" : "Copy code"}
295
+ class="size-7 rounded-md border-border/80 bg-background/90 text-muted-foreground backdrop-blur-xs hover:bg-muted hover:text-foreground cursor-pointer shadow-2xs"
296
+ >
297
+ <Show when={clipboard.copied()} fallback={<Copy class="size-3.5" />}>
298
+ <Check class="size-3.5 text-emerald-500 dark:text-emerald-400" />
299
+ </Show>
300
+ </TooltipTrigger>
301
+ <TooltipContent class="text-[10px] py-1 px-2">
302
+ {clipboard.copied() ? "Copied!" : "Copy code"}
303
+ </TooltipContent>
304
+ </Tooltip>
305
+ </div>
306
+ </Show>
307
+
308
+ {/* Code Container */}
309
+ <div class="overflow-x-auto p-4 text-[13px] leading-relaxed [scrollbar-width:thin]">
310
+ <Show
311
+ when={local.children}
312
+ fallback={
313
+ <div
314
+ class="font-mono text-sm leading-relaxed overflow-x-auto [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0 [&>pre]:!border-none"
315
+ innerHTML={highlightedHtml() || `<pre class="text-foreground/90"><code>${escapeHtml(cleanCode())}</code></pre>`}
316
+ />
317
+ }
318
+ >
319
+ <pre class="shiki nikala-code-block m-0 overflow-x-auto bg-transparent p-0 font-mono text-xs">
320
+ {local.children}
321
+ </pre>
322
+ </Show>
323
+ </div>
324
+ </div>
325
+ );
326
+ };
@@ -0,0 +1,105 @@
1
+ import {
2
+ splitProps,
3
+ type JSX,
4
+ type ParentComponent,
5
+ } from "solid-js";
6
+ import {
7
+ Tabs,
8
+ TabsList,
9
+ TabsTrigger,
10
+ TabsContent,
11
+ } from "@/components/ui/tabs";
12
+ import { cn } from "@/lib/cn";
13
+
14
+ export interface CodeGroupProps
15
+ extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onChange"> {
16
+ /** Controlled active tab value */
17
+ value?: string;
18
+ /** Default active tab value */
19
+ defaultValue?: string;
20
+ /** Callback fired when active tab changes */
21
+ onChange?: (value: string) => void;
22
+ class?: string;
23
+ }
24
+
25
+ /**
26
+ * Tabbed code container composed directly from Nikala UI Tabs primitives.
27
+ */
28
+ export const CodeGroup: ParentComponent<CodeGroupProps> = (props) => {
29
+ const [local, rest] = splitProps(props, [
30
+ "value",
31
+ "defaultValue",
32
+ "onChange",
33
+ "class",
34
+ "children",
35
+ ]);
36
+
37
+ return (
38
+ <Tabs
39
+ value={local.value}
40
+ defaultValue={local.defaultValue}
41
+ onChange={local.onChange}
42
+ class={cn(
43
+ "my-4 w-full gap-0 overflow-hidden rounded-lg border border-border bg-muted/40 font-mono text-sm shadow-2xs",
44
+ local.class
45
+ )}
46
+ {...rest}
47
+ >
48
+ {local.children}
49
+ </Tabs>
50
+ );
51
+ };
52
+
53
+ export interface CodeGroupListProps extends JSX.HTMLAttributes<HTMLDivElement> {
54
+ class?: string;
55
+ }
56
+
57
+ export const CodeGroupList: ParentComponent<CodeGroupListProps> = (props) => {
58
+ const [local, rest] = splitProps(props, ["class", "children"]);
59
+ return (
60
+ <div class={cn("flex h-9 items-center justify-between border-b border-border/70 bg-muted/70 px-3 select-none", local.class)}>
61
+ <TabsList class="h-auto bg-background/60 p-0.5 border border-border/50 gap-0.5 rounded-md" {...rest}>
62
+ {local.children}
63
+ </TabsList>
64
+ </div>
65
+ );
66
+ };
67
+
68
+ export interface CodeGroupTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
69
+ value: string;
70
+ class?: string;
71
+ }
72
+
73
+ export const CodeGroupTrigger: ParentComponent<CodeGroupTriggerProps> = (props) => {
74
+ const [local, rest] = splitProps(props, ["value", "class", "children"]);
75
+ return (
76
+ <TabsTrigger
77
+ value={local.value}
78
+ class={cn(
79
+ "rounded-xs px-2 py-0.5 text-[11px] font-mono transition-colors cursor-pointer data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:font-semibold data-[state=active]:shadow-2xs",
80
+ local.class
81
+ )}
82
+ {...rest}
83
+ >
84
+ {local.children}
85
+ </TabsTrigger>
86
+ );
87
+ };
88
+
89
+ export interface CodeGroupContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
90
+ value: string;
91
+ class?: string;
92
+ }
93
+
94
+ export const CodeGroupContent: ParentComponent<CodeGroupContentProps> = (props) => {
95
+ const [local, rest] = splitProps(props, ["value", "class", "children"]);
96
+ return (
97
+ <TabsContent
98
+ value={local.value}
99
+ class={cn("p-4 text-[13px] leading-relaxed overflow-x-auto m-0 focus-visible:outline-none", local.class)}
100
+ {...rest}
101
+ >
102
+ {local.children}
103
+ </TabsContent>
104
+ );
105
+ };
@@ -1,44 +1,88 @@
1
1
  import { splitProps, type JSX, type ValidComponent } from "solid-js";
2
2
  import { Check, ChevronDown, X } from "lucide-solid";
3
- import { ScrollArea } from "./scroll-area";
3
+ import { ScrollArea } from "@/components/ui/scroll-area";
4
4
  import * as ComboboxPrimitive from "@kobalte/core/combobox";
5
5
  import { cn } from "@/lib/cn";
6
6
 
7
7
  export type ComboboxRootProps<Option = any, OptGroup = any, T extends ValidComponent = "div"> =
8
- ComboboxPrimitive.ComboboxRootProps<Option, OptGroup, T>;
8
+ ComboboxPrimitive.ComboboxRootProps<Option, OptGroup, T> & {
9
+ class?: string;
10
+ children?: JSX.Element;
11
+ triggerMode?: "input" | "focus" | "both" | "manual";
12
+ };
9
13
 
10
14
  /**
11
- * Root Combobox primitive component wrapper.
15
+ * Root Combobox component providing search, single/multi-selection, and group support.
16
+ * `triggerMode="focus"` or `triggerMode="both"` enables opening dropdown on input click/focus.
12
17
  */
13
18
  export const Combobox = <Option = any, OptGroup = any, T extends ValidComponent = "div">(
14
19
  props: ComboboxRootProps<Option, OptGroup, T>
15
20
  ) => {
16
- return <ComboboxPrimitive.Root {...props} />;
21
+ const [local, rest] = splitProps(props as ComboboxRootProps, ["class", "children", "triggerMode"]);
22
+
23
+ return (
24
+ <ComboboxPrimitive.Root
25
+ triggerMode={local.triggerMode ?? "input"}
26
+ class={cn("relative w-full", local.class)}
27
+ {...(rest as any)}
28
+ >
29
+ {local.children}
30
+ </ComboboxPrimitive.Root>
31
+ );
17
32
  };
18
33
 
19
34
  export type ComboboxControlProps<Option = any, T extends ValidComponent = "div"> =
20
35
  ComboboxPrimitive.ComboboxControlProps<Option, T> & {
21
36
  class?: string;
22
37
  children?: JSX.Element;
38
+ clearable?: boolean;
39
+ onClear?: () => void;
23
40
  };
24
41
 
25
42
  /**
26
- * Input container box supporting single or multi-select tokens.
43
+ * Input container for Combobox supporting search input, selected tags, and clear button.
27
44
  */
28
45
  export const ComboboxControl = <Option = any, T extends ValidComponent = "div">(
29
46
  props: ComboboxControlProps<Option, T>
30
47
  ) => {
31
- const [local, rest] = splitProps(props as ComboboxControlProps, ["class", "children"]);
48
+ const [local, rest] = splitProps(props as ComboboxControlProps, [
49
+ "class",
50
+ "children",
51
+ "clearable",
52
+ "onClear",
53
+ ]);
32
54
 
33
55
  return (
34
56
  <ComboboxPrimitive.Control
35
57
  class={cn(
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",
58
+ "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",
37
59
  local.class
38
60
  )}
39
61
  {...(rest as any)}
40
62
  >
41
- {local.children}
63
+ <div class="flex flex-wrap items-center gap-1.5 flex-1 min-w-0">
64
+ {local.children}
65
+ </div>
66
+
67
+ <div class="flex items-center gap-1 shrink-0 self-center">
68
+ {local.clearable && (
69
+ <button
70
+ type="button"
71
+ tabIndex={-1}
72
+ onClick={(e) => {
73
+ e.stopPropagation();
74
+ if (local.onClear) local.onClear();
75
+ }}
76
+ class="rounded-sm p-0.5 opacity-60 hover:opacity-100 hover:bg-accent text-foreground transition-opacity focus:outline-none cursor-pointer"
77
+ aria-label="Clear selection"
78
+ >
79
+ <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
80
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
81
+ </svg>
82
+ </button>
83
+ )}
84
+ <ComboboxTrigger />
85
+ </div>
42
86
  </ComboboxPrimitive.Control>
43
87
  );
44
88
  };
@@ -50,7 +94,8 @@ export type ComboboxInputProps<T extends ValidComponent = "input"> =
50
94
  };
51
95
 
52
96
  /**
53
- * Filter search input field.
97
+ * Search input field embedded within ComboboxControl.
98
+ * Supports `openOnFocus` to automatically trigger the dropdown when focused or clicked.
54
99
  */
55
100
  export const ComboboxInput = <T extends ValidComponent = "input">(
56
101
  props: ComboboxInputProps<T>
@@ -135,10 +180,12 @@ export const ComboboxToken = <Option = any>(props: ComboboxTokenProps<Option>) =
135
180
  e.stopPropagation();
136
181
  if (local.onRemove) local.onRemove();
137
182
  }}
138
- class="rounded-xs opacity-70 hover:opacity-100 focus:outline-none cursor-pointer text-muted-foreground hover:text-foreground"
183
+ class="rounded-xs p-0.5 hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground transition-colors focus:outline-none cursor-pointer"
184
+ aria-label="Remove tag"
139
185
  >
140
- <X class="h-3 w-3" />
141
- <span class="sr-only">Remove</span>
186
+ <svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
187
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
188
+ </svg>
142
189
  </button>
143
190
  </span>
144
191
  );
@@ -191,17 +238,19 @@ export const ComboboxItem = <T extends ValidComponent = "li">(
191
238
  return (
192
239
  <ComboboxPrimitive.Item
193
240
  class={cn(
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",
241
+ "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",
195
242
  local.class
196
243
  )}
197
- {...(rest as any)}
244
+ {...rest}
198
245
  >
199
- <ComboboxPrimitive.ItemLabel class="flex-1 truncate">
246
+ <ComboboxPrimitive.ItemIndicator class="absolute left-2 flex h-4 w-4 items-center justify-center text-primary">
247
+ <svg class="h-4 w-4 fill-none stroke-current stroke-2" viewBox="0 0 24 24">
248
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
249
+ </svg>
250
+ </ComboboxPrimitive.ItemIndicator>
251
+ <ComboboxPrimitive.ItemLabel class="flex items-center gap-2 w-full truncate">
200
252
  {local.children}
201
253
  </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>
205
254
  </ComboboxPrimitive.Item>
206
255
  );
207
256
  };
@@ -232,3 +281,19 @@ export const ComboboxGroup = <T extends ValidComponent = "li">(
232
281
  </ComboboxPrimitive.Section>
233
282
  );
234
283
  };
284
+
285
+ /**
286
+ * Empty state notice when no matching search items exist.
287
+ */
288
+ export const ComboboxEmpty = (props: { class?: string; children?: JSX.Element }) => {
289
+ return (
290
+ <div class={cn("py-6 text-center text-sm text-muted-foreground", props.class)}>
291
+ {props.children || "No matching items found."}
292
+ </div>
293
+ );
294
+ };
295
+
296
+ /**
297
+ * Hidden native select element for form integrations.
298
+ */
299
+ export const ComboboxHiddenSelect = ComboboxPrimitive.HiddenSelect;