@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,67 @@
1
+ import { splitProps, type Component, type JSX } from "solid-js";
2
+ import { cn } from "@/lib/cn";
3
+ import { Label, type LabelProps } from "./label";
4
+
5
+ export interface FieldProps extends JSX.HTMLAttributes<HTMLDivElement> {
6
+ class?: string;
7
+ }
8
+
9
+ /** A consistent layout wrapper for labels, controls, descriptions, and errors. */
10
+ export const Field: Component<FieldProps> = (props) => {
11
+ const [local, rest] = splitProps(props, ["class", "children"]);
12
+
13
+ return (
14
+ <div class={cn("grid w-full gap-1.5", local.class)} {...rest}>
15
+ {local.children}
16
+ </div>
17
+ );
18
+ };
19
+
20
+ export interface FieldLabelProps extends LabelProps {
21
+ class?: string;
22
+ children?: JSX.Element;
23
+ }
24
+
25
+ export const FieldLabel: Component<FieldLabelProps> = (props) => {
26
+ const [local, rest] = splitProps(props, ["class", "children"]);
27
+
28
+ return (
29
+ <Label class={cn("text-sm font-medium", local.class)} {...rest}>
30
+ {local.children}
31
+ </Label>
32
+ );
33
+ };
34
+
35
+ export interface FieldDescriptionProps
36
+ extends JSX.HTMLAttributes<HTMLParagraphElement> {
37
+ class?: string;
38
+ }
39
+
40
+ export const FieldDescription: Component<FieldDescriptionProps> = (props) => {
41
+ const [local, rest] = splitProps(props, ["class", "children"]);
42
+
43
+ return (
44
+ <p class={cn("text-sm text-muted-foreground", local.class)} {...rest}>
45
+ {local.children}
46
+ </p>
47
+ );
48
+ };
49
+
50
+ export interface FieldErrorProps
51
+ extends JSX.HTMLAttributes<HTMLParagraphElement> {
52
+ class?: string;
53
+ }
54
+
55
+ export const FieldError: Component<FieldErrorProps> = (props) => {
56
+ const [local, rest] = splitProps(props, ["class", "children"]);
57
+
58
+ return (
59
+ <p
60
+ role="alert"
61
+ class={cn("text-sm font-medium text-destructive", local.class)}
62
+ {...rest}
63
+ >
64
+ {local.children}
65
+ </p>
66
+ );
67
+ };
@@ -0,0 +1,36 @@
1
+ import { Show, splitProps, type JSX } from "solid-js";
2
+ import type { CreateFormReturn } from "@nikala-ui/hooks";
3
+ import { FieldError, type FieldErrorProps } from "./field";
4
+
5
+ type FormState<T extends Record<string, any>> = Pick<
6
+ CreateFormReturn<T>,
7
+ "errors" | "touched"
8
+ >;
9
+
10
+ export interface FormMessageProps<
11
+ T extends Record<string, any>,
12
+ K extends keyof T,
13
+ > extends Omit<FieldErrorProps, "children"> {
14
+ form: FormState<T>;
15
+ name: K;
16
+ /** Show the message even before the field has been touched. */
17
+ showUntouched?: boolean;
18
+ }
19
+
20
+ /** Displays a field's validation error from createForm when it should be visible. */
21
+ export function FormMessage<
22
+ T extends Record<string, any>,
23
+ K extends keyof T,
24
+ >(props: FormMessageProps<T, K>): JSX.Element {
25
+ const [local, rest] = splitProps(props, ["form", "name", "showUntouched", "class"]);
26
+ const error = () => local.form.errors()[local.name] as string | undefined;
27
+ const touched = () => local.form.touched()[local.name] === true;
28
+
29
+ return (
30
+ <Show when={error() && (local.showUntouched || touched())}>
31
+ <FieldError class={local.class} {...rest}>
32
+ {error()}
33
+ </FieldError>
34
+ </Show>
35
+ );
36
+ }
@@ -0,0 +1,21 @@
1
+ import { splitProps, type Component, type JSX } from "solid-js";
2
+ import { cn } from "@/lib/cn";
3
+
4
+ export interface FormProps extends JSX.FormHTMLAttributes<HTMLFormElement> {
5
+ /** Indicates that the form is currently processing a submission. */
6
+ loading?: boolean;
7
+ class?: string;
8
+ }
9
+
10
+ /** A semantic form layout wrapper designed to work with the createForm hook. */
11
+ export const Form: Component<FormProps> = (props) => {
12
+ const [local, rest] = splitProps(props, ["loading", "class"]);
13
+
14
+ return (
15
+ <form
16
+ aria-busy={local.loading ? "true" : undefined}
17
+ class={cn("w-full space-y-5", local.class)}
18
+ {...rest}
19
+ />
20
+ );
21
+ };
@@ -0,0 +1,34 @@
1
+ import { splitProps, type Component, type JSX } from "solid-js";
2
+ import { Button, type ButtonProps } from "./button";
3
+ import { cn } from "@/lib/cn";
4
+
5
+ export interface IconButtonProps
6
+ extends Omit<ButtonProps, "size" | "children"> {
7
+ /** Accessible label for the icon-only button. */
8
+ label: string;
9
+ size?: "sm" | "default" | "lg";
10
+ children?: JSX.Element;
11
+ }
12
+
13
+ /** An accessible square button intended for a single icon action. */
14
+ export const IconButton: Component<IconButtonProps> = (props) => {
15
+ const [local, rest] = splitProps(props, ["label", "size", "class", "children"]);
16
+
17
+ return (
18
+ <Button
19
+ size="icon"
20
+ aria-label={local.label}
21
+ class={cn(
22
+ {
23
+ "size-8": local.size === "sm",
24
+ "size-9": !local.size || local.size === "default",
25
+ "size-10": local.size === "lg",
26
+ },
27
+ local.class
28
+ )}
29
+ {...rest}
30
+ >
31
+ {local.children}
32
+ </Button>
33
+ );
34
+ };
@@ -0,0 +1,150 @@
1
+ import { createSignal, onCleanup, splitProps, type Component, type JSX } from "solid-js";
2
+ import { NumberField as KobalteNumberField } from "@kobalte/core/number-field";
3
+ import { Plus, Minus } from "lucide-solid";
4
+ import { Input } from "./input";
5
+ import { Button } from "./button";
6
+ import { createLongPress } from "@nikala-ui/hooks";
7
+ import { cn } from "@/lib/cn";
8
+
9
+ export interface NumberInputProps {
10
+ value?: number;
11
+ defaultValue?: number;
12
+ onValueChange?: (value: number) => void;
13
+ minValue?: number;
14
+ maxValue?: number;
15
+ step?: number;
16
+ allowNegative?: boolean;
17
+ disabled?: boolean;
18
+ readOnly?: boolean;
19
+ class?: string;
20
+ id?: string;
21
+ }
22
+
23
+ export const NumberInput: Component<NumberInputProps> = (props) => {
24
+ const [local, rest] = splitProps(props, [
25
+ "value",
26
+ "defaultValue",
27
+ "onValueChange",
28
+ "minValue",
29
+ "maxValue",
30
+ "step",
31
+ "allowNegative",
32
+ "disabled",
33
+ "readOnly",
34
+ "class",
35
+ "id",
36
+ ]);
37
+
38
+ const [internalVal, setInternalVal] = createSignal<number>(
39
+ local.value !== undefined
40
+ ? local.value
41
+ : local.defaultValue !== undefined
42
+ ? local.defaultValue
43
+ : 0
44
+ );
45
+
46
+ let autoRepeatInterval: ReturnType<typeof setInterval> | undefined;
47
+
48
+ const currentVal = () => (local.value !== undefined ? local.value : internalVal());
49
+
50
+ const effectiveMin = () => {
51
+ if (local.minValue !== undefined) return local.minValue;
52
+ return local.allowNegative ? -Infinity : 0;
53
+ };
54
+
55
+ const effectiveMax = () => {
56
+ if (local.maxValue !== undefined) return local.maxValue;
57
+ return Infinity;
58
+ };
59
+
60
+ const handleValueChange = (val: number) => {
61
+ if (isNaN(val)) return;
62
+ const clamped = Math.max(effectiveMin(), Math.min(effectiveMax(), val));
63
+ setInternalVal(clamped);
64
+ local.onValueChange?.(clamped);
65
+ };
66
+
67
+ const stopAutoRepeat = () => {
68
+ if (autoRepeatInterval) {
69
+ clearInterval(autoRepeatInterval);
70
+ autoRepeatInterval = undefined;
71
+ }
72
+ };
73
+
74
+ const startAutoRepeat = (direction: 1 | -1) => {
75
+ if (local.disabled || local.readOnly) return;
76
+ stopAutoRepeat();
77
+ const stepAmount = local.step || 1;
78
+ autoRepeatInterval = setInterval(() => {
79
+ handleValueChange(currentVal() + direction * stepAmount);
80
+ }, 75);
81
+ };
82
+
83
+ /* Nikala UI createLongPress hook for Increment Trigger */
84
+ const incrementLongPress = createLongPress(
85
+ () => {
86
+ startAutoRepeat(1);
87
+ },
88
+ {
89
+ threshold: 300,
90
+ onCancel: stopAutoRepeat,
91
+ }
92
+ );
93
+
94
+ /* Nikala UI createLongPress hook for Decrement Trigger */
95
+ const decrementLongPress = createLongPress(
96
+ () => {
97
+ startAutoRepeat(-1);
98
+ },
99
+ {
100
+ threshold: 300,
101
+ onCancel: stopAutoRepeat,
102
+ }
103
+ );
104
+
105
+ onCleanup(() => stopAutoRepeat());
106
+
107
+ return (
108
+ <KobalteNumberField
109
+ value={currentVal()}
110
+ onRawValueChange={handleValueChange}
111
+ minValue={effectiveMin()}
112
+ maxValue={effectiveMax()}
113
+ step={local.step || 1}
114
+ disabled={local.disabled}
115
+ readOnly={local.readOnly}
116
+ id={local.id}
117
+ class={cn("relative flex items-center max-w-[160px]", local.class)}
118
+ {...rest}
119
+ >
120
+ <KobalteNumberField.Input
121
+ as={Input}
122
+ class="pr-16 text-center font-mono focus-visible:ring-1"
123
+ />
124
+
125
+ <div class="absolute right-1 flex items-center gap-0.5">
126
+ <KobalteNumberField.DecrementTrigger
127
+ as={Button}
128
+ variant="ghost"
129
+ size="icon"
130
+ class="h-7 w-7 rounded-sm p-0 text-muted-foreground hover:text-foreground select-none"
131
+ aria-label="Decrement value"
132
+ {...decrementLongPress.props}
133
+ >
134
+ <Minus class="h-3.5 w-3.5" />
135
+ </KobalteNumberField.DecrementTrigger>
136
+
137
+ <KobalteNumberField.IncrementTrigger
138
+ as={Button}
139
+ variant="ghost"
140
+ size="icon"
141
+ class="h-7 w-7 rounded-sm p-0 text-muted-foreground hover:text-foreground select-none"
142
+ aria-label="Increment value"
143
+ {...incrementLongPress.props}
144
+ >
145
+ <Plus class="h-3.5 w-3.5" />
146
+ </KobalteNumberField.IncrementTrigger>
147
+ </div>
148
+ </KobalteNumberField>
149
+ );
150
+ };
@@ -0,0 +1,193 @@
1
+ import {
2
+ createSignal,
3
+ createContext,
4
+ useContext,
5
+ splitProps,
6
+ type Component,
7
+ type JSX,
8
+ type Accessor,
9
+ } from "solid-js";
10
+ import { createElementSize } from "@nikala-ui/hooks";
11
+ import { GripVertical, GripHorizontal } from "lucide-solid";
12
+ import { cn } from "@/lib/cn";
13
+
14
+ export interface ResizableContextValue {
15
+ orientation: Accessor<"horizontal" | "vertical">;
16
+ registerPanel: (id: string, initialSizes: number) => void;
17
+ sizes: Accessor<Record<string, number>>;
18
+ startDragging: (handleIndex: number, event: PointerEvent) => void;
19
+ containerRef: () => HTMLDivElement | undefined;
20
+ }
21
+
22
+ const ResizableContext = createContext<ResizableContextValue>();
23
+
24
+ export interface ResizableGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
25
+ orientation?: "horizontal" | "vertical";
26
+ class?: string;
27
+ children?: JSX.Element;
28
+ }
29
+
30
+ export const ResizableGroup: Component<ResizableGroupProps> = (props) => {
31
+ const [local, rest] = splitProps(props, ["orientation", "class", "children"]);
32
+ const orientation = () => local.orientation || "horizontal";
33
+ let containerEl: HTMLDivElement | undefined;
34
+
35
+ const [panelOrder, setPanelOrder] = createSignal<string[]>([]);
36
+ const [sizes, setSizes] = createSignal<Record<string, number>>({});
37
+
38
+ const registerPanel = (id: string, initialSize: number) => {
39
+ setPanelOrder((prev) => (prev.includes(id) ? prev : [...prev, id]));
40
+ setSizes((prev) => (prev[id] !== undefined ? prev : { ...prev, [id]: initialSize }));
41
+ };
42
+
43
+ const startDragging = (handleIndex: number, event: PointerEvent) => {
44
+ if (!containerEl) return;
45
+ event.preventDefault();
46
+
47
+ const order = panelOrder();
48
+ if (handleIndex < 0 || handleIndex >= order.length - 1) return;
49
+
50
+ const leftId = order[handleIndex];
51
+ const rightId = order[handleIndex + 1];
52
+
53
+ const isHoriz = orientation() === "horizontal";
54
+ const startPos = isHoriz ? event.clientX : event.clientY;
55
+ const rect = containerEl.getBoundingClientRect();
56
+ const totalPx = isHoriz ? rect.width : rect.height;
57
+
58
+ const startLeftPct = sizes()[leftId] ?? 50;
59
+ const startRightPct = sizes()[rightId] ?? 50;
60
+
61
+ const onPointerMove = (e: PointerEvent) => {
62
+ const currentPos = isHoriz ? e.clientX : e.clientY;
63
+ const deltaPx = currentPos - startPos;
64
+ const deltaPct = (deltaPx / totalPx) * 100;
65
+
66
+ let newLeft = startLeftPct + deltaPct;
67
+ let newRight = startRightPct - deltaPct;
68
+
69
+ if (newLeft < 10) {
70
+ newLeft = 10;
71
+ newRight = startLeftPct + startRightPct - 10;
72
+ } else if (newRight < 10) {
73
+ newRight = 10;
74
+ newLeft = startLeftPct + startRightPct - 10;
75
+ }
76
+
77
+ setSizes((prev) => ({
78
+ ...prev,
79
+ [leftId]: newLeft,
80
+ [rightId]: newRight,
81
+ }));
82
+ };
83
+
84
+ const onPointerUp = () => {
85
+ window.removeEventListener("pointermove", onPointerMove);
86
+ window.removeEventListener("pointerup", onPointerUp);
87
+ };
88
+
89
+ window.addEventListener("pointermove", onPointerMove);
90
+ window.addEventListener("pointerup", onPointerUp);
91
+ };
92
+
93
+ return (
94
+ <ResizableContext.Provider
95
+ value={{
96
+ orientation,
97
+ registerPanel,
98
+ sizes,
99
+ startDragging,
100
+ containerRef: () => containerEl,
101
+ }}
102
+ >
103
+ <div
104
+ ref={containerEl}
105
+ class={cn(
106
+ "flex h-full w-full overflow-hidden rounded-lg border border-border bg-background",
107
+ orientation() === "vertical" ? "flex-col" : "flex-row",
108
+ local.class
109
+ )}
110
+ {...rest}
111
+ >
112
+ {local.children}
113
+ </div>
114
+ </ResizableContext.Provider>
115
+ );
116
+ };
117
+
118
+ export interface ResizablePanelProps extends JSX.HTMLAttributes<HTMLDivElement> {
119
+ id: string;
120
+ initialSize?: number;
121
+ class?: string;
122
+ children?: JSX.Element;
123
+ }
124
+
125
+ export const ResizablePanel: Component<ResizablePanelProps> = (props) => {
126
+ const [local, rest] = splitProps(props, ["id", "initialSize", "class", "children"]);
127
+ const ctx = useContext(ResizableContext);
128
+
129
+ if (!ctx) {
130
+ throw new Error("ResizablePanel must be used within a ResizableGroup");
131
+ }
132
+
133
+ ctx.registerPanel(local.id, local.initialSize ?? 50);
134
+
135
+ const currentPct = () => ctx.sizes()[local.id] ?? local.initialSize ?? 50;
136
+
137
+ const containerSize = createElementSize(() => ctx.containerRef());
138
+
139
+ return (
140
+ <div
141
+ class={cn("overflow-auto transition-[flex-basis] duration-75", local.class)}
142
+ style={{
143
+ "flex-basis": `${currentPct()}%`,
144
+ "flex-grow": 0,
145
+ "flex-shrink": 0,
146
+ }}
147
+ {...rest}
148
+ >
149
+ {local.children}
150
+ </div>
151
+ );
152
+ };
153
+
154
+ export interface ResizableHandleProps extends JSX.HTMLAttributes<HTMLDivElement> {
155
+ handleIndex: number;
156
+ withHandle?: boolean;
157
+ class?: string;
158
+ }
159
+
160
+ export const ResizableHandle: Component<ResizableHandleProps> = (props) => {
161
+ const [local, rest] = splitProps(props, ["handleIndex", "withHandle", "class"]);
162
+ const ctx = useContext(ResizableContext);
163
+
164
+ if (!ctx) {
165
+ throw new Error("ResizableHandle must be used within a ResizableGroup");
166
+ }
167
+
168
+ const isHoriz = () => ctx.orientation() === "horizontal";
169
+
170
+ return (
171
+ <div
172
+ role="separator"
173
+ tabIndex={0}
174
+ class={cn(
175
+ "relative flex select-none items-center justify-center bg-border transition-colors hover:bg-primary/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-col-resize",
176
+ isHoriz() ? "h-full w-1.5 cursor-col-resize" : "h-1.5 w-full cursor-row-resize",
177
+ local.class
178
+ )}
179
+ onPointerDown={(e) => ctx.startDragging(local.handleIndex, e)}
180
+ {...rest}
181
+ >
182
+ {local.withHandle && (
183
+ <div class="z-10 flex h-4 w-3 items-center justify-center rounded-xs border border-border bg-muted shadow-2xs">
184
+ {isHoriz() ? (
185
+ <GripVertical class="h-2.5 w-2.5 text-muted-foreground" />
186
+ ) : (
187
+ <GripHorizontal class="h-2.5 w-2.5 text-muted-foreground" />
188
+ )}
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ };