devnonla-ui 0.2.0 → 0.3.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.
@@ -0,0 +1,246 @@
1
+ export type ColorFormat = "hex" | "rgb" | "hsb";
2
+
3
+ export type HsbaColor = {
4
+ h: number;
5
+ s: number;
6
+ b: number;
7
+ a: number;
8
+ };
9
+
10
+ export type RgbaColor = {
11
+ r: number;
12
+ g: number;
13
+ b: number;
14
+ a: number;
15
+ };
16
+
17
+ export type ColorType = string | Color;
18
+
19
+ const HEX = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
20
+ const RGB = /^rgba?\(\s*([0-9.]+%?)\s*[, ]\s*([0-9.]+%?)\s*[, ]\s*([0-9.]+%?)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i;
21
+ const HSL = /^hsla?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)%\s*[, ]\s*([0-9.]+)%(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i;
22
+ const HSB = /^hsba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)%\s*[, ]\s*([0-9.]+)%(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i;
23
+ const HSV = /^hsva?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)%\s*[, ]\s*([0-9.]+)%(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i;
24
+
25
+ export function clamp(n: number, min: number, max: number) {
26
+ return Math.min(max, Math.max(min, n));
27
+ }
28
+
29
+ function round(n: number, digits = 0) {
30
+ const f = 10 ** digits;
31
+ return Math.round(n * f) / f;
32
+ }
33
+
34
+ function wrapHue(h: number) {
35
+ const n = ((h % 360) + 360) % 360;
36
+ return n;
37
+ }
38
+
39
+ function hexByte(n: number) {
40
+ return clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
41
+ }
42
+
43
+ function parseChannel(raw: string, max: number) {
44
+ const t = raw.trim();
45
+ if (t.endsWith("%")) return clamp((parseFloat(t) / 100) * max, 0, max);
46
+ return clamp(parseFloat(t), 0, max);
47
+ }
48
+
49
+ function parseAlpha(raw: string | undefined) {
50
+ if (raw == null || raw === "") return 1;
51
+ const t = raw.trim();
52
+ if (t.endsWith("%")) return clamp(parseFloat(t) / 100, 0, 1);
53
+ const n = parseFloat(t);
54
+ return n > 1 ? clamp(n / 255, 0, 1) : clamp(n, 0, 1);
55
+ }
56
+
57
+ export function hsbaToRgba({ h, s, b, a }: HsbaColor): RgbaColor {
58
+ const hue = wrapHue(h);
59
+ const sat = clamp(s, 0, 1);
60
+ const val = clamp(b, 0, 1);
61
+ const c = val * sat;
62
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
63
+ const m = val - c;
64
+ let r = 0;
65
+ let g = 0;
66
+ let bl = 0;
67
+ if (hue < 60) [r, g, bl] = [c, x, 0];
68
+ else if (hue < 120) [r, g, bl] = [x, c, 0];
69
+ else if (hue < 180) [r, g, bl] = [0, c, x];
70
+ else if (hue < 240) [r, g, bl] = [0, x, c];
71
+ else if (hue < 300) [r, g, bl] = [x, 0, c];
72
+ else [r, g, bl] = [c, 0, x];
73
+ return {
74
+ r: Math.round((r + m) * 255),
75
+ g: Math.round((g + m) * 255),
76
+ b: Math.round((bl + m) * 255),
77
+ a: clamp(a, 0, 1),
78
+ };
79
+ }
80
+
81
+ export function rgbaToHsba({ r, g, b, a }: RgbaColor): HsbaColor {
82
+ const R = clamp(r, 0, 255) / 255;
83
+ const G = clamp(g, 0, 255) / 255;
84
+ const B = clamp(b, 0, 255) / 255;
85
+ const max = Math.max(R, G, B);
86
+ const min = Math.min(R, G, B);
87
+ const d = max - min;
88
+ let h = 0;
89
+ if (d !== 0) {
90
+ if (max === R) h = ((G - B) / d) % 6;
91
+ else if (max === G) h = (B - R) / d + 2;
92
+ else h = (R - G) / d + 4;
93
+ h *= 60;
94
+ if (h < 0) h += 360;
95
+ }
96
+ return { h, s: max === 0 ? 0 : d / max, b: max, a: clamp(a, 0, 1) };
97
+ }
98
+
99
+ function hslToHsba(h: number, s: number, l: number, a: number): HsbaColor {
100
+ const sat = clamp(s, 0, 1);
101
+ const lit = clamp(l, 0, 1);
102
+ const v = lit + sat * Math.min(lit, 1 - lit);
103
+ const sv = v === 0 ? 0 : 2 * (1 - lit / v);
104
+ return { h: wrapHue(h), s: sv, b: v, a: clamp(a, 0, 1) };
105
+ }
106
+
107
+ function expandHex(hex: string) {
108
+ if (hex.length === 3 || hex.length === 4) {
109
+ return hex
110
+ .split("")
111
+ .map((c) => c + c)
112
+ .join("");
113
+ }
114
+ return hex;
115
+ }
116
+
117
+ function parseHex(raw: string): HsbaColor | null {
118
+ const m = HEX.exec(raw.trim());
119
+ if (!m) return null;
120
+ const hex = expandHex(m[1]!.toLowerCase());
121
+ const r = parseInt(hex.slice(0, 2), 16);
122
+ const g = parseInt(hex.slice(2, 4), 16);
123
+ const b = parseInt(hex.slice(4, 6), 16);
124
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
125
+ return rgbaToHsba({ r, g, b, a });
126
+ }
127
+
128
+ function parseCss(raw: string): HsbaColor | null {
129
+ const t = raw.trim();
130
+ if (!t) return null;
131
+ if (t.toLowerCase() === "transparent") return { h: 0, s: 0, b: 0, a: 0 };
132
+ const hex = parseHex(t);
133
+ if (hex) return hex;
134
+ const rgb = RGB.exec(t);
135
+ if (rgb) {
136
+ return rgbaToHsba({
137
+ r: parseChannel(rgb[1]!, 255),
138
+ g: parseChannel(rgb[2]!, 255),
139
+ b: parseChannel(rgb[3]!, 255),
140
+ a: parseAlpha(rgb[4]),
141
+ });
142
+ }
143
+ const hsl = HSL.exec(t);
144
+ if (hsl) return hslToHsba(parseFloat(hsl[1]!), parseFloat(hsl[2]!) / 100, parseFloat(hsl[3]!) / 100, parseAlpha(hsl[4]));
145
+ const hsb = HSB.exec(t) ?? HSV.exec(t);
146
+ if (hsb) {
147
+ return {
148
+ h: wrapHue(parseFloat(hsb[1]!)),
149
+ s: clamp(parseFloat(hsb[2]!) / 100, 0, 1),
150
+ b: clamp(parseFloat(hsb[3]!) / 100, 0, 1),
151
+ a: parseAlpha(hsb[4]),
152
+ };
153
+ }
154
+ return null;
155
+ }
156
+
157
+ export function isColor(value: unknown): value is Color {
158
+ return value instanceof Color;
159
+ }
160
+
161
+ export class Color {
162
+ private readonly meta: HsbaColor;
163
+
164
+ constructor(input?: ColorType | HsbaColor | RgbaColor | null) {
165
+ this.meta = toHsba(input);
166
+ }
167
+
168
+ toHsb(): HsbaColor {
169
+ return { h: this.meta.h, s: this.meta.s, b: this.meta.b, a: this.meta.a };
170
+ }
171
+
172
+ toRgb(): RgbaColor {
173
+ return hsbaToRgba(this.meta);
174
+ }
175
+
176
+ toHex(ignoreAlpha = false): string {
177
+ const { r, g, b, a } = this.toRgb();
178
+ const hex = `${hexByte(r)}${hexByte(g)}${hexByte(b)}`;
179
+ if (ignoreAlpha || a >= 1) return hex;
180
+ return `${hex}${hexByte(a * 255)}`;
181
+ }
182
+
183
+ toHexString(ignoreAlpha = false): string {
184
+ return `#${this.toHex(ignoreAlpha)}`;
185
+ }
186
+
187
+ toRgbString(): string {
188
+ const { r, g, b, a } = this.toRgb();
189
+ if (a < 1) return `rgba(${r}, ${g}, ${b}, ${round(a, 2)})`;
190
+ return `rgb(${r}, ${g}, ${b})`;
191
+ }
192
+
193
+ toHsbString(): string {
194
+ const { h, s, b, a } = this.meta;
195
+ const H = Math.round(wrapHue(h));
196
+ const S = Math.round(s * 100);
197
+ const B = Math.round(b * 100);
198
+ if (a < 1) return `hsba(${H}, ${S}%, ${B}%, ${round(a, 2)})`;
199
+ return `hsb(${H}, ${S}%, ${B}%)`;
200
+ }
201
+
202
+ toCssString(): string {
203
+ return this.toRgbString();
204
+ }
205
+
206
+ toString(format: ColorFormat = "hex"): string {
207
+ if (format === "rgb") return this.toRgbString();
208
+ if (format === "hsb") return this.toHsbString();
209
+ return this.toHexString();
210
+ }
211
+
212
+ static fromHsba(hsba: HsbaColor): Color {
213
+ return new Color(hsba);
214
+ }
215
+ }
216
+
217
+ function isHsbaLike(value: object): value is HsbaColor {
218
+ return "h" in value && "s" in value && "b" in value;
219
+ }
220
+
221
+ function isRgbaLike(value: object): value is RgbaColor {
222
+ return "r" in value && "g" in value && "b" in value;
223
+ }
224
+
225
+ function toHsba(input?: ColorType | HsbaColor | RgbaColor | null): HsbaColor {
226
+ if (input == null || input === "") return { h: 215, s: 0.91, b: 1, a: 1 };
227
+ if (isColor(input)) return input.toHsb();
228
+ if (typeof input === "string") return parseCss(input) ?? { h: 215, s: 0.91, b: 1, a: 1 };
229
+ if (isHsbaLike(input)) {
230
+ return { h: wrapHue(input.h), s: clamp(input.s, 0, 1), b: clamp(input.b, 0, 1), a: clamp(input.a ?? 1, 0, 1) };
231
+ }
232
+ if (isRgbaLike(input)) return rgbaToHsba({ r: input.r, g: input.g, b: input.b, a: input.a ?? 1 });
233
+ return { h: 215, s: 0.91, b: 1, a: 1 };
234
+ }
235
+
236
+ export function parseColor(input?: ColorType | null): Color | null {
237
+ if (input == null || input === "") return null;
238
+ if (isColor(input)) return input;
239
+ if (typeof input === "string") {
240
+ const parsed = parseCss(input);
241
+ return parsed ? Color.fromHsba(parsed) : null;
242
+ }
243
+ return new Color(input);
244
+ }
245
+
246
+ export const DEFAULT_COLOR = new Color("#1677ff");
@@ -1,7 +1,6 @@
1
- import { useState } from "react";
2
1
  import type { ControllerRenderProps, FieldValues } from "react-hook-form";
3
- import { Input } from "../../input/Input";
4
- import { Popover } from "../../popover/Popover";
2
+ import { ColorPicker, type PresetColorType } from "../../colorpicker/ColorPicker";
3
+ import type { ControlSize } from "../../lib/sizes";
5
4
 
6
5
  type Props = {
7
6
  field: ControllerRenderProps<FieldValues, string>;
@@ -9,55 +8,21 @@ type Props = {
9
8
  status?: "error" | "warning";
10
9
  };
11
10
 
12
- const FALLBACK = "#000000";
13
-
14
11
  export function FieldColor({ field, options, status }: Props) {
15
- const raw = typeof field.value === "string" ? field.value : field.value ? String(field.value) : "";
16
- const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(raw) ? raw : FALLBACK;
17
- const [open, setOpen] = useState(false);
18
-
12
+ const opts = options ?? {};
13
+ const raw = typeof field.value === "string" ? field.value : field.value != null ? String(field.value) : "";
19
14
  return (
20
- <div className="flex items-center gap-2">
21
- <Popover
22
- open={open}
23
- onOpenChange={setOpen}
24
- trigger="click"
25
- placement="bottom"
26
- content={
27
- <div className="flex flex-col gap-2 p-1 min-w-45">
28
- <input
29
- type="color"
30
- value={hex}
31
- onChange={(e) => field.onChange(e.target.value)}
32
- className="h-24 w-full cursor-pointer border-0 bg-transparent p-0"
33
- aria-label="Pick color"
34
- />
35
- <Input
36
- value={raw || hex}
37
- onChange={(e) => field.onChange(e.target.value)}
38
- placeholder="#000000"
39
- status={status}
40
- className="font-mono text-sm"
41
- {...(options as object)}
42
- />
43
- </div>
44
- }
45
- >
46
- <button
47
- type="button"
48
- className="size-9 shrink-0 cursor-pointer rounded border border-input p-0"
49
- style={{ backgroundColor: raw || hex }}
50
- aria-label="Color picker"
51
- />
52
- </Popover>
53
- <Input
54
- className="flex-1 font-mono text-sm"
55
- value={raw}
56
- onChange={(e) => field.onChange(e.target.value)}
57
- onBlur={field.onBlur}
58
- placeholder="#000000"
59
- status={status}
60
- />
61
- </div>
15
+ <ColorPicker
16
+ value={raw || null}
17
+ onChange={(color) => field.onChange(color.toHexString())}
18
+ onClear={() => field.onChange("")}
19
+ showText
20
+ allowClear={opts.allowClear !== false}
21
+ disabled={opts.disabled as boolean | undefined}
22
+ disabledAlpha={opts.disabledAlpha as boolean | undefined}
23
+ size={opts.size as ControlSize | undefined}
24
+ status={status}
25
+ presets={opts.presets as PresetColorType[] | undefined}
26
+ />
62
27
  );
63
28
  }
package/src/index.ts CHANGED
@@ -22,8 +22,8 @@ export type { SwitchProps, SwitchVariant } from "./switch/Switch";
22
22
  export { Checkbox } from "./checkbox/Checkbox";
23
23
  export type { CheckboxProps } from "./checkbox/Checkbox";
24
24
 
25
- export { ColorPicker } from "./colorpicker/ColorPicker";
26
- export type { ColorPickerProps } from "./colorpicker/ColorPicker";
25
+ export { ColorPicker, Color } from "./colorpicker/ColorPicker";
26
+ export type { ColorPickerProps, ColorFormat, ColorType, PresetColorType, ColorPickerSemanticSlot } from "./colorpicker/ColorPicker";
27
27
 
28
28
  export { Tooltip } from "./tooltip/Tooltip";
29
29
  export type { TooltipProps, TooltipPlacement } from "./tooltip/Tooltip";
@@ -77,6 +77,9 @@ export type { PopconfirmProps } from "./popconfirm/Popconfirm";
77
77
  export { Drawer } from "./drawer/Drawer";
78
78
  export type { DrawerProps, DrawerPlacement } from "./drawer/Drawer";
79
79
 
80
+ export { Splitter, SplitterPanel } from "./splitter/Splitter";
81
+ export type { SplitterProps, SplitterPanelProps, SplitterOrientation, SplitterSize, SplitterSemanticSlot } from "./splitter/Splitter";
82
+
80
83
  export { Table } from "./table/Table";
81
84
  export type {
82
85
  TableProps,
@@ -0,0 +1,330 @@
1
+ import {
2
+ Children,
3
+ Fragment,
4
+ type CSSProperties,
5
+ type KeyboardEvent,
6
+ type PointerEvent,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ isValidElement,
10
+ useCallback,
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from "react";
17
+ import { cn } from "../lib/cn";
18
+ import { applyDrag, distributeSizes, parseSplitterSize, scaleSizes, type SplitterSize } from "./sizes";
19
+
20
+ export type { SplitterSize };
21
+ export type SplitterOrientation = "horizontal" | "vertical";
22
+ export type SplitterSemanticSlot = "root" | "panel" | "dragger";
23
+
24
+ export type SplitterPanelProps = {
25
+ children?: ReactNode;
26
+ className?: string;
27
+ style?: CSSProperties;
28
+ defaultSize?: SplitterSize;
29
+ size?: SplitterSize;
30
+ min?: SplitterSize;
31
+ max?: SplitterSize;
32
+ resizable?: boolean;
33
+ destroyOnHidden?: boolean;
34
+ };
35
+
36
+ export type SplitterProps = {
37
+ children?: ReactNode;
38
+ className?: string;
39
+ style?: CSSProperties;
40
+ orientation?: SplitterOrientation;
41
+ /** When set with `orientation`, `orientation` wins. */
42
+ vertical?: boolean;
43
+ draggerIcon?: ReactNode;
44
+ destroyOnHidden?: boolean;
45
+ classNames?: Partial<Record<SplitterSemanticSlot, string>>;
46
+ styles?: Partial<Record<SplitterSemanticSlot, CSSProperties>>;
47
+ onResize?: (sizes: number[]) => void;
48
+ onResizeStart?: (sizes: number[]) => void;
49
+ onResizeEnd?: (sizes: number[]) => void;
50
+ };
51
+
52
+ type PanelConfig = SplitterPanelProps & { key: string };
53
+
54
+ type DragSession = {
55
+ index: number;
56
+ startPos: number;
57
+ startLeft: number;
58
+ startRight: number;
59
+ };
60
+
61
+ const PANEL_MARK = "__NONLA_SPLITTER_PANEL__";
62
+
63
+ function isPanelElement(child: ReactNode): child is ReactElement<SplitterPanelProps> {
64
+ return isValidElement(child) && Boolean((child.type as { [PANEL_MARK]?: boolean })[PANEL_MARK]);
65
+ }
66
+
67
+ function collectPanels(children: ReactNode): PanelConfig[] {
68
+ return Children.toArray(children).filter(isPanelElement).map((child, index) => ({
69
+ key: child.key != null ? String(child.key) : String(index),
70
+ ...child.props,
71
+ }));
72
+ }
73
+
74
+ function SplitterPanel(_props: SplitterPanelProps) {
75
+ return null;
76
+ }
77
+ SplitterPanel.displayName = "Splitter.Panel";
78
+ (SplitterPanel as typeof SplitterPanel & { [PANEL_MARK]: true })[PANEL_MARK] = true;
79
+
80
+ function SplitterRoot({
81
+ children,
82
+ className,
83
+ style,
84
+ orientation,
85
+ vertical = false,
86
+ draggerIcon,
87
+ destroyOnHidden = false,
88
+ classNames,
89
+ styles,
90
+ onResize,
91
+ onResizeStart,
92
+ onResizeEnd,
93
+ }: SplitterProps) {
94
+ const isVertical = (orientation ?? (vertical ? "vertical" : "horizontal")) === "vertical";
95
+ const panels = useMemo(() => collectPanels(children), [children]);
96
+ const count = panels.length;
97
+
98
+ const rootRef = useRef<HTMLDivElement>(null);
99
+ const sizesRef = useRef<number[]>([]);
100
+ const dragRef = useRef<DragSession | null>(null);
101
+
102
+ const [container, setContainer] = useState(0);
103
+ const [sizes, setSizes] = useState<number[]>([]);
104
+ const [dragging, setDragging] = useState(false);
105
+
106
+ sizesRef.current = sizes;
107
+
108
+ const sizeKey = panels.map((panel) => String(panel.size ?? "")).join("|");
109
+
110
+ const resolve = useCallback(
111
+ (prev: number[] | null) => {
112
+ const scaled = prev && prev.length === count && container > 0 ? scaleSizes(prev, container) : null;
113
+ const declared = panels.map((panel, index) => {
114
+ const controlled = parseSplitterSize(panel.size, container);
115
+ if (controlled != null) return controlled;
116
+ if (scaled?.[index] != null) return scaled[index];
117
+ return parseSplitterSize(panel.defaultSize, container);
118
+ });
119
+ return distributeSizes(declared, container);
120
+ },
121
+ [container, count, panels],
122
+ );
123
+
124
+ useLayoutEffect(() => {
125
+ const el = rootRef.current;
126
+ if (!el) return;
127
+ const read = () => {
128
+ const next = isVertical ? el.clientHeight : el.clientWidth;
129
+ setContainer((curr) => (Math.abs(curr - next) < 0.5 ? curr : next));
130
+ };
131
+ read();
132
+ const ro = new ResizeObserver(read);
133
+ ro.observe(el);
134
+ return () => ro.disconnect();
135
+ }, [isVertical]);
136
+
137
+ useLayoutEffect(() => {
138
+ if (container <= 0 || count === 0 || dragRef.current) return;
139
+ setSizes((prev) => {
140
+ const next = resolve(prev.length === count ? prev : null);
141
+ if (prev.length === next.length && prev.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 0.5)) return prev;
142
+ return next;
143
+ });
144
+ }, [container, count, resolve, sizeKey]);
145
+
146
+ const commitSizes = (next: number[]) => {
147
+ setSizes(next);
148
+ onResize?.(next);
149
+ };
150
+
151
+ const boundsOf = (index: number) => {
152
+ const panel = panels[index]!;
153
+ return {
154
+ min: parseSplitterSize(panel.min, container) ?? 0,
155
+ max: parseSplitterSize(panel.max, container) ?? container,
156
+ };
157
+ };
158
+
159
+ const movePair = (index: number, delta: number, source: number[]) => {
160
+ const left = boundsOf(index);
161
+ const right = boundsOf(index + 1);
162
+ const [nextLeft, nextRight] = applyDrag(source[index] ?? 0, source[index + 1] ?? 0, delta, left.min, left.max, right.min, right.max);
163
+ const next = source.slice();
164
+ next[index] = nextLeft;
165
+ next[index + 1] = nextRight;
166
+ return next;
167
+ };
168
+
169
+ const onBarPointerDown = (index: number, event: PointerEvent<HTMLDivElement>) => {
170
+ if (event.button !== 0) return;
171
+ if (panels[index]?.resizable === false || panels[index + 1]?.resizable === false) return;
172
+ event.preventDefault();
173
+ event.stopPropagation();
174
+ event.currentTarget.setPointerCapture(event.pointerId);
175
+ const pos = isVertical ? event.clientY : event.clientX;
176
+ dragRef.current = {
177
+ index,
178
+ startPos: pos,
179
+ startLeft: sizes[index] ?? 0,
180
+ startRight: sizes[index + 1] ?? 0,
181
+ };
182
+ setDragging(true);
183
+ onResizeStart?.(sizes);
184
+ };
185
+
186
+ const onBarPointerUp = (event?: PointerEvent<HTMLDivElement>) => {
187
+ if (!dragRef.current) return;
188
+ if (event) {
189
+ try {
190
+ event.currentTarget.releasePointerCapture(event.pointerId);
191
+ } catch {
192
+ /* already released */
193
+ }
194
+ }
195
+ dragRef.current = null;
196
+ setDragging(false);
197
+ onResizeEnd?.(sizesRef.current);
198
+ };
199
+
200
+ useEffect(() => {
201
+ if (!dragging) return;
202
+ const onMove = (event: globalThis.PointerEvent) => {
203
+ const drag = dragRef.current;
204
+ if (!drag) return;
205
+ const pos = isVertical ? event.clientY : event.clientX;
206
+ const source = Array.from({ length: count }, (_, i) => (i === drag.index ? drag.startLeft : i === drag.index + 1 ? drag.startRight : (sizesRef.current[i] ?? 0)));
207
+ commitSizes(movePair(drag.index, pos - drag.startPos, source));
208
+ };
209
+ const onUp = () => onBarPointerUp();
210
+ window.addEventListener("pointermove", onMove);
211
+ window.addEventListener("pointerup", onUp);
212
+ window.addEventListener("pointercancel", onUp);
213
+ return () => {
214
+ window.removeEventListener("pointermove", onMove);
215
+ window.removeEventListener("pointerup", onUp);
216
+ window.removeEventListener("pointercancel", onUp);
217
+ };
218
+ }, [dragging, count, isVertical]);
219
+
220
+ const onBarKeyDown = (index: number, event: KeyboardEvent<HTMLDivElement>) => {
221
+ if (panels[index]?.resizable === false || panels[index + 1]?.resizable === false) return;
222
+ const step = event.shiftKey ? 32 : 8;
223
+ const backward = isVertical ? event.key === "ArrowUp" : event.key === "ArrowLeft";
224
+ const forward = isVertical ? event.key === "ArrowDown" : event.key === "ArrowRight";
225
+ if (!backward && !forward) return;
226
+ event.preventDefault();
227
+ commitSizes(movePair(index, backward ? -step : step, sizes));
228
+ };
229
+
230
+ useEffect(() => {
231
+ if (!dragging) return;
232
+ const prevCursor = document.body.style.cursor;
233
+ const prevSelect = document.body.style.userSelect;
234
+ document.body.style.cursor = isVertical ? "row-resize" : "col-resize";
235
+ document.body.style.userSelect = "none";
236
+ return () => {
237
+ document.body.style.cursor = prevCursor;
238
+ document.body.style.userSelect = prevSelect;
239
+ };
240
+ }, [dragging, isVertical]);
241
+
242
+ return (
243
+ <div
244
+ ref={rootRef}
245
+ className={cn("nonla-splitter relative flex h-full min-h-0 min-w-0 w-full overflow-hidden", isVertical ? "flex-col" : "flex-row", classNames?.root, className)}
246
+ style={{ ...styles?.root, ...style }}
247
+ data-orientation={isVertical ? "vertical" : "horizontal"}
248
+ >
249
+ {panels.map((panel, index) => {
250
+ const size = sizes[index] ?? 0;
251
+ const hidden = size <= 0;
252
+ const destroy = hidden && (panel.destroyOnHidden ?? destroyOnHidden);
253
+ const resizable = panel.resizable !== false && panels[index + 1]?.resizable !== false;
254
+
255
+ return (
256
+ <Fragment key={panel.key}>
257
+ <div
258
+ className={cn("nonla-splitter-panel min-h-0 min-w-0 overflow-auto", hidden && "pointer-events-none", classNames?.panel, panel.className)}
259
+ style={{
260
+ flexGrow: container <= 0 ? 1 : 0,
261
+ flexShrink: 0,
262
+ flexBasis: container <= 0 ? 0 : size,
263
+ ...(isVertical ? { height: container <= 0 ? undefined : size, width: "100%" } : { width: container <= 0 ? undefined : size, height: "100%" }),
264
+ ...styles?.panel,
265
+ ...panel.style,
266
+ }}
267
+ >
268
+ {destroy ? null : panel.children}
269
+ </div>
270
+ {index < count - 1 ? (
271
+ <div
272
+ className={cn("nonla-splitter-bar relative z-10 shrink-0", isVertical ? "h-0 w-full" : "h-full w-0")}
273
+ style={styles?.dragger}
274
+ >
275
+ <div
276
+ role="separator"
277
+ aria-orientation={isVertical ? "vertical" : "horizontal"}
278
+ aria-valuenow={Math.round(size)}
279
+ aria-valuemin={0}
280
+ aria-valuemax={Math.round(container)}
281
+ tabIndex={resizable ? 0 : -1}
282
+ className={cn(
283
+ "group absolute z-10 touch-none select-none",
284
+ isVertical ? "inset-x-0 top-1/2 h-3 -translate-y-1/2 cursor-row-resize" : "inset-y-0 left-1/2 w-3 -translate-x-1/2 cursor-col-resize",
285
+ !resizable && "cursor-default",
286
+ classNames?.dragger,
287
+ )}
288
+ onPointerDown={resizable ? (event) => onBarPointerDown(index, event) : undefined}
289
+ onKeyDown={resizable ? (event) => onBarKeyDown(index, event) : undefined}
290
+ >
291
+ <span
292
+ className={cn(
293
+ "pointer-events-none absolute rounded-full transition-colors",
294
+ isVertical ? "inset-x-0 top-1/2 h-0.5 -translate-y-1/2" : "inset-y-0 left-1/2 w-0.5 -translate-x-1/2",
295
+ dragging ? "bg-brand" : "bg-border group-hover:bg-brand group-focus-visible:bg-brand",
296
+ )}
297
+ />
298
+ {draggerIcon ? (
299
+ <span
300
+ className={cn(
301
+ "pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-sm bg-card text-muted-foreground shadow-button-outline",
302
+ isVertical ? "h-1.5 w-5" : "h-5 w-1.5",
303
+ )}
304
+ >
305
+ {draggerIcon}
306
+ </span>
307
+ ) : (
308
+ <span
309
+ className={cn(
310
+ "pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-sm bg-card shadow-button-outline transition-opacity",
311
+ "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100",
312
+ dragging && "opacity-100",
313
+ isVertical ? "h-1 w-8" : "h-8 w-1",
314
+ )}
315
+ />
316
+ )}
317
+ </div>
318
+ </div>
319
+ ) : null}
320
+ </Fragment>
321
+ );
322
+ })}
323
+ </div>
324
+ );
325
+ }
326
+
327
+ type SplitterComponent = typeof SplitterRoot & { Panel: typeof SplitterPanel };
328
+
329
+ export const Splitter = Object.assign(SplitterRoot, { Panel: SplitterPanel }) as SplitterComponent;
330
+ export { SplitterPanel };