devnonla-ui 0.2.0 → 0.4.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.
- package/README.md +3 -3
- package/package.json +2 -1
- package/src/app/App.tsx +4 -4
- package/src/button/Button.tsx +1 -1
- package/src/button/ButtonCopy.tsx +11 -21
- package/src/calendar/Calendar.tsx +1 -1
- package/src/chat/AgentPanel.tsx +3 -3
- package/src/chat/index.ts +35 -51
- package/src/chat/message-ui/ChatError.tsx +2 -4
- package/src/chat/message-ui/ChatInput.tsx +5 -9
- package/src/chat/message-ui/ChatThinking.tsx +2 -3
- package/src/chat/message-ui/ChatUserMessage.tsx +4 -5
- package/src/chat/message-ui/ChatWelcome.tsx +3 -8
- package/src/chat/message-ui/MermaidBlock.tsx +7 -47
- package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +8 -11
- package/src/chat/tool-ui/BackgroundTasksBar.tsx +13 -14
- package/src/chat/tool-ui/CallAgentToolUI.tsx +10 -4
- package/src/chat/tool-ui/ChatToolCall.tsx +11 -17
- package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +2 -2
- package/src/chat/tool-ui/ReadSkillToolUI.tsx +3 -3
- package/src/chat/tool-ui/RunJsToolUI.tsx +2 -2
- package/src/chat/tool-ui/ToolUiTrailing.tsx +3 -7
- package/src/chat/tool-ui/WebFetchToolUI.tsx +22 -13
- package/src/checkbox/Checkbox.tsx +1 -1
- package/src/codeblock/CodeBlock.tsx +17 -19
- package/src/colorpicker/ColorPicker.tsx +694 -30
- package/src/colorpicker/color.ts +246 -0
- package/src/datepicker/DatePicker.tsx +3 -3
- package/src/desktop/DesktopHeader.tsx +15 -20
- package/src/desktop/DesktopIcon.tsx +1 -1
- package/src/desktop/DesktopWindow.tsx +54 -30
- package/src/form/Form.tsx +1 -1
- package/src/form/FormItem.tsx +2 -2
- package/src/form/index.ts +23 -24
- package/src/form/variants/FieldColor.tsx +16 -51
- package/src/index.ts +167 -196
- package/src/input/Input.tsx +14 -12
- package/src/input/SearchInput.tsx +5 -2
- package/src/lib/sizes.ts +1 -1
- package/src/modal/Modal.tsx +3 -0
- package/src/pagination/Pagination.tsx +528 -51
- package/src/scroll/OverlayScroll.tsx +58 -37
- package/src/select/Select.tsx +2 -2
- package/src/spin/Spin.tsx +14 -17
- package/src/splitter/Splitter.tsx +330 -0
- package/src/splitter/sizes.ts +67 -0
- package/src/styles.css +28 -3
- package/src/switch/Switch.tsx +1 -1
- package/src/table/Table.tsx +43 -18
- package/src/timepicker/TimePicker.tsx +2 -2
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { type PointerEvent as ReactPointerEvent, type
|
|
1
|
+
import { type ReactNode, type PointerEvent as ReactPointerEvent, type Ref, type UIEventHandler, useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
|
|
4
4
|
export type OverlayScrollVisibility = "hover" | "always";
|
|
5
5
|
|
|
6
6
|
const HIDE_MS = 1000;
|
|
7
|
+
const MIN_THUMB = 32;
|
|
7
8
|
|
|
8
9
|
export type OverlayScrollProps = {
|
|
9
10
|
/** `hover` shows the thumb on hover / while scrolling. `always` keeps it visible. */
|
|
10
11
|
visibility?: OverlayScrollVisibility;
|
|
12
|
+
/** Size to content (honors max-height on `className`) instead of filling the parent. */
|
|
13
|
+
autoHeight?: boolean;
|
|
11
14
|
className?: string;
|
|
12
15
|
innerClassName?: string;
|
|
13
16
|
children?: ReactNode;
|
|
@@ -15,7 +18,10 @@ export type OverlayScrollProps = {
|
|
|
15
18
|
scrollRef?: Ref<HTMLDivElement>;
|
|
16
19
|
};
|
|
17
20
|
|
|
18
|
-
type Thumb = {
|
|
21
|
+
type Thumb = { offset: number; size: number; shown: boolean };
|
|
22
|
+
type Axis = "x" | "y";
|
|
23
|
+
|
|
24
|
+
const HIDDEN: Thumb = { offset: 0, size: 0, shown: false };
|
|
19
25
|
|
|
20
26
|
function assignRef(ref: Ref<HTMLDivElement> | undefined, node: HTMLDivElement | null) {
|
|
21
27
|
if (!ref) return;
|
|
@@ -23,27 +29,36 @@ function assignRef(ref: Ref<HTMLDivElement> | undefined, node: HTMLDivElement |
|
|
|
23
29
|
else ref.current = node;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
function measure(scroll: number, scrollSize: number, client: number): Thumb {
|
|
33
|
+
const overflow = scrollSize - client;
|
|
34
|
+
if (overflow <= 1) return HIDDEN;
|
|
35
|
+
const size = Math.max(MIN_THUMB, (client / scrollSize) * client);
|
|
36
|
+
const offset = (scroll / overflow) * (client - size);
|
|
37
|
+
return { offset, size, shown: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canScrollX(el: HTMLDivElement) {
|
|
41
|
+
const overflow = getComputedStyle(el).overflowX;
|
|
42
|
+
return overflow === "auto" || overflow === "scroll";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function OverlayScroll({ visibility = "hover", autoHeight = false, className, innerClassName, children, onScroll, scrollRef }: OverlayScrollProps) {
|
|
27
46
|
const elRef = useRef<HTMLDivElement | null>(null);
|
|
28
47
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
|
-
const
|
|
48
|
+
const vThumbRef = useRef<Thumb>(HIDDEN);
|
|
49
|
+
const hThumbRef = useRef<Thumb>(HIDDEN);
|
|
30
50
|
const [scrolling, setScrolling] = useState(false);
|
|
31
51
|
const [dragging, setDragging] = useState(false);
|
|
32
|
-
const [
|
|
33
|
-
|
|
52
|
+
const [vThumb, setVThumb] = useState<Thumb>(HIDDEN);
|
|
53
|
+
const [hThumb, setHThumb] = useState<Thumb>(HIDDEN);
|
|
54
|
+
vThumbRef.current = vThumb;
|
|
55
|
+
hThumbRef.current = hThumb;
|
|
34
56
|
|
|
35
57
|
const updateThumb = useCallback(() => {
|
|
36
58
|
const el = elRef.current;
|
|
37
59
|
if (!el) return;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (overflow <= 1) {
|
|
41
|
-
setThumb((prev) => (prev.shown ? { top: 0, height: 0, shown: false } : prev));
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
const height = Math.max(32, (clientHeight / scrollHeight) * clientHeight);
|
|
45
|
-
const top = (scrollTop / overflow) * (clientHeight - height);
|
|
46
|
-
setThumb({ top, height, shown: true });
|
|
60
|
+
setVThumb(measure(el.scrollTop, el.scrollHeight, el.clientHeight));
|
|
61
|
+
setHThumb(canScrollX(el) ? measure(el.scrollLeft, el.scrollWidth, el.clientWidth) : HIDDEN);
|
|
47
62
|
}, []);
|
|
48
63
|
|
|
49
64
|
const setNode = useCallback(
|
|
@@ -64,7 +79,7 @@ export function OverlayScroll({ visibility = "hover", className, innerClassName,
|
|
|
64
79
|
const child = el.firstElementChild;
|
|
65
80
|
if (child) ro.observe(child);
|
|
66
81
|
return () => ro.disconnect();
|
|
67
|
-
}, [updateThumb]);
|
|
82
|
+
}, [updateThumb, innerClassName, autoHeight]);
|
|
68
83
|
|
|
69
84
|
useLayoutEffect(() => {
|
|
70
85
|
return () => {
|
|
@@ -82,27 +97,30 @@ export function OverlayScroll({ visibility = "hover", className, innerClassName,
|
|
|
82
97
|
onScroll?.(e);
|
|
83
98
|
};
|
|
84
99
|
|
|
85
|
-
const onThumbPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
100
|
+
const onThumbPointerDown = (axis: Axis) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
86
101
|
const el = elRef.current;
|
|
87
|
-
const current =
|
|
102
|
+
const current = axis === "y" ? vThumbRef.current : hThumbRef.current;
|
|
88
103
|
if (!el || !current.shown || e.button !== 0) return;
|
|
89
104
|
e.preventDefault();
|
|
90
105
|
e.stopPropagation();
|
|
91
106
|
|
|
92
|
-
const overflow = el.scrollHeight - el.clientHeight;
|
|
93
|
-
const track = el.clientHeight - current.
|
|
107
|
+
const overflow = axis === "y" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth;
|
|
108
|
+
const track = (axis === "y" ? el.clientHeight : el.clientWidth) - current.size;
|
|
94
109
|
if (overflow <= 0 || track <= 0) return;
|
|
95
110
|
|
|
96
|
-
const
|
|
97
|
-
const
|
|
111
|
+
const start = axis === "y" ? e.clientY : e.clientX;
|
|
112
|
+
const startOffset = current.offset;
|
|
98
113
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
99
114
|
setDragging(true);
|
|
100
115
|
setScrolling(true);
|
|
101
116
|
|
|
102
117
|
const onMove = (ev: PointerEvent) => {
|
|
103
118
|
if (ev.pointerId !== e.pointerId) return;
|
|
104
|
-
const
|
|
105
|
-
|
|
119
|
+
const delta = (axis === "y" ? ev.clientY : ev.clientX) - start;
|
|
120
|
+
const next = Math.min(track, Math.max(0, startOffset + delta));
|
|
121
|
+
const pos = (next / track) * overflow;
|
|
122
|
+
if (axis === "y") el.scrollTop = pos;
|
|
123
|
+
else el.scrollLeft = pos;
|
|
106
124
|
};
|
|
107
125
|
const onUp = (ev: PointerEvent) => {
|
|
108
126
|
if (ev.pointerId !== e.pointerId) return;
|
|
@@ -119,24 +137,27 @@ export function OverlayScroll({ visibility = "hover", className, innerClassName,
|
|
|
119
137
|
};
|
|
120
138
|
|
|
121
139
|
const thumbVisible = visibility === "always" || dragging || scrolling;
|
|
140
|
+
const thumbClass = cn(
|
|
141
|
+
"nonla-overlay-thumb absolute z-20 rounded-full transition-opacity duration-150",
|
|
142
|
+
visibility === "always" || dragging
|
|
143
|
+
? "pointer-events-auto opacity-100"
|
|
144
|
+
: "pointer-events-none opacity-0 group-hover/scroll:pointer-events-auto group-hover/scroll:opacity-100 group-data-scrolling/scroll:pointer-events-auto group-data-scrolling/scroll:opacity-100",
|
|
145
|
+
);
|
|
122
146
|
|
|
123
147
|
return (
|
|
124
148
|
<div className={cn("group/scroll relative min-h-0 min-w-0 w-full overflow-hidden", className)} data-scrolling={thumbVisible ? "true" : undefined}>
|
|
125
|
-
<div
|
|
149
|
+
<div
|
|
150
|
+
ref={setNode}
|
|
151
|
+
onScroll={handleScroll}
|
|
152
|
+
className={cn("nonla-scroll-hidden overflow-y-auto overflow-x-hidden [overflow-anchor:none]", autoHeight ? "max-h-[inherit]" : "absolute inset-0", innerClassName)}
|
|
153
|
+
>
|
|
126
154
|
{children}
|
|
127
155
|
</div>
|
|
128
|
-
{
|
|
129
|
-
<div
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"nonla-overlay-thumb absolute z-20 rounded-full transition-opacity duration-150",
|
|
134
|
-
visibility === "always" || dragging
|
|
135
|
-
? "pointer-events-auto opacity-100"
|
|
136
|
-
: "pointer-events-none opacity-0 group-hover/scroll:pointer-events-auto group-hover/scroll:opacity-100 group-data-scrolling/scroll:pointer-events-auto group-data-scrolling/scroll:opacity-100",
|
|
137
|
-
)}
|
|
138
|
-
style={{ right: 2, width: "var(--nonla-scrollbar-size)", height: thumb.height, transform: `translateY(${thumb.top}px)` }}
|
|
139
|
-
/>
|
|
156
|
+
{vThumb.shown ? (
|
|
157
|
+
<div aria-hidden onPointerDown={onThumbPointerDown("y")} className={thumbClass} style={{ right: 2, width: "var(--nonla-scrollbar-size)", height: vThumb.size, transform: `translateY(${vThumb.offset}px)` }} />
|
|
158
|
+
) : null}
|
|
159
|
+
{hThumb.shown ? (
|
|
160
|
+
<div aria-hidden onPointerDown={onThumbPointerDown("x")} className={thumbClass} style={{ bottom: 2, height: "var(--nonla-scrollbar-size)", width: hThumb.size, transform: `translateX(${hThumb.offset}px)` }} />
|
|
140
161
|
) : null}
|
|
141
162
|
</div>
|
|
142
163
|
);
|
package/src/select/Select.tsx
CHANGED
package/src/spin/Spin.tsx
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { type ReactNode, useEffect, useState } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
|
-
import { type ControlSize, getSizeTokens, useControlSize } from "../lib/sizes";
|
|
3
|
+
import { type ControlSize, getSizeTokens, normalizeSize, useControlSize } from "../lib/sizes";
|
|
4
4
|
|
|
5
|
-
export type SpinVariant = "default" | "agent"
|
|
5
|
+
export type SpinVariant = "default" | "agent";
|
|
6
6
|
|
|
7
7
|
export type SpinProps = {
|
|
8
8
|
spinning?: boolean;
|
|
9
9
|
tip?: ReactNode;
|
|
10
10
|
size?: ControlSize;
|
|
11
|
-
/** Visual style — agent
|
|
11
|
+
/** Visual style — `agent` is the 3×3 snake for AI run states. */
|
|
12
12
|
variant?: SpinVariant;
|
|
13
13
|
/** Replace the built-in indicator entirely. */
|
|
14
14
|
indicator?: ReactNode;
|
|
@@ -75,11 +75,17 @@ function stepSnake(snake: Snake): Snake {
|
|
|
75
75
|
return [next, head, mid];
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** 3×3 metrics so `small` is visibly smaller than default (icon-aligned). */
|
|
79
|
+
function matrixMetrics(size: ControlSize | undefined): { cell: number; gap: number } {
|
|
80
|
+
const s = normalizeSize(size);
|
|
81
|
+
if (s === "small") return { cell: 3, gap: 1.5 };
|
|
82
|
+
if (s === "large") return { cell: 6, gap: 3 };
|
|
83
|
+
return { cell: 4, gap: 2 };
|
|
84
|
+
}
|
|
85
|
+
|
|
78
86
|
/** 3×3 fixed dots — 3 adjacent lit cells crawl randomly (snake). */
|
|
79
|
-
function MatrixSnake({ size, tickMs =
|
|
80
|
-
const
|
|
81
|
-
const cell = Math.max(4, Math.round(5 * scale));
|
|
82
|
-
const gap = Math.max(2, Math.round(2 * scale));
|
|
87
|
+
function MatrixSnake({ size, tickMs = 240 }: { size: ControlSize | undefined; tickMs?: number }) {
|
|
88
|
+
const { cell, gap } = matrixMetrics(size);
|
|
83
89
|
const dim = cell * 3 + gap * 2;
|
|
84
90
|
const [snake, setSnake] = useState<Snake>(() => randomSnake());
|
|
85
91
|
|
|
@@ -112,17 +118,8 @@ function MatrixSnake({ size, tickMs = 220 }: { size: ControlSize | undefined; ti
|
|
|
112
118
|
);
|
|
113
119
|
}
|
|
114
120
|
|
|
115
|
-
function AgentSpinner({ size }: { size: ControlSize | undefined }) {
|
|
116
|
-
return <MatrixSnake size={size} tickMs={240} />;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function SubAgentSpinner({ size }: { size: ControlSize | undefined }) {
|
|
120
|
-
return <MatrixSnake size={size} tickMs={180} />;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
121
|
function Spinner({ size, variant }: { size: ControlSize | undefined; variant: SpinVariant }) {
|
|
124
|
-
if (variant === "agent") return <
|
|
125
|
-
if (variant === "subAgent") return <SubAgentSpinner size={size} />;
|
|
122
|
+
if (variant === "agent") return <MatrixSnake size={size} />;
|
|
126
123
|
return <DefaultSpinner size={size} />;
|
|
127
124
|
}
|
|
128
125
|
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
Fragment,
|
|
5
|
+
isValidElement,
|
|
6
|
+
type KeyboardEvent,
|
|
7
|
+
type PointerEvent,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
type ReactNode,
|
|
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, type SplitterSize, scaleSizes } 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 };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type SplitterSize = number | string;
|
|
2
|
+
|
|
3
|
+
export function parseSplitterSize(value: SplitterSize | undefined, container: number): number | undefined {
|
|
4
|
+
if (value == null || container < 0) return undefined;
|
|
5
|
+
if (typeof value === "number") return Number.isFinite(value) ? Math.max(0, value) : undefined;
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
if (!trimmed) return undefined;
|
|
8
|
+
if (trimmed.endsWith("%")) {
|
|
9
|
+
const n = Number.parseFloat(trimmed.slice(0, -1));
|
|
10
|
+
return Number.isFinite(n) ? Math.max(0, (n / 100) * container) : undefined;
|
|
11
|
+
}
|
|
12
|
+
const n = Number.parseFloat(trimmed);
|
|
13
|
+
return Number.isFinite(n) ? Math.max(0, n) : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function distributeSizes(declared: Array<number | undefined>, container: number): number[] {
|
|
17
|
+
const count = declared.length;
|
|
18
|
+
if (count === 0) return [];
|
|
19
|
+
if (container <= 0) return declared.map((size) => size ?? 0);
|
|
20
|
+
|
|
21
|
+
let known = 0;
|
|
22
|
+
let unknown = 0;
|
|
23
|
+
for (const size of declared) {
|
|
24
|
+
if (size == null) unknown += 1;
|
|
25
|
+
else known += size;
|
|
26
|
+
}
|
|
27
|
+
const leftover = container - known;
|
|
28
|
+
const fill = unknown > 0 ? Math.max(0, leftover) / unknown : 0;
|
|
29
|
+
const sizes = declared.map((size) => size ?? fill);
|
|
30
|
+
const sum = sizes.reduce<number>((total, size) => total + size, 0);
|
|
31
|
+
if (sum <= 0) return sizes.map(() => container / count);
|
|
32
|
+
if (Math.abs(sum - container) > 0.5) return sizes.map((size) => (size / sum) * container);
|
|
33
|
+
return sizes;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function scaleSizes(sizes: number[], container: number): number[] {
|
|
37
|
+
const count = sizes.length;
|
|
38
|
+
if (count === 0 || container <= 0) return sizes;
|
|
39
|
+
const sum = sizes.reduce<number>((total, size) => total + size, 0);
|
|
40
|
+
if (sum <= 0) return sizes.map(() => container / count);
|
|
41
|
+
return sizes.map((size) => (size / sum) * container);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function applyDrag(
|
|
45
|
+
left: number,
|
|
46
|
+
right: number,
|
|
47
|
+
delta: number,
|
|
48
|
+
leftMin: number,
|
|
49
|
+
leftMax: number,
|
|
50
|
+
rightMin: number,
|
|
51
|
+
rightMax: number,
|
|
52
|
+
): [number, number] {
|
|
53
|
+
const pair = left + right;
|
|
54
|
+
let nextLeft = Math.min(leftMax, Math.max(leftMin, left + delta));
|
|
55
|
+
let nextRight = pair - nextLeft;
|
|
56
|
+
if (nextRight < rightMin) {
|
|
57
|
+
nextRight = rightMin;
|
|
58
|
+
nextLeft = pair - nextRight;
|
|
59
|
+
}
|
|
60
|
+
if (nextRight > rightMax) {
|
|
61
|
+
nextRight = rightMax;
|
|
62
|
+
nextLeft = pair - nextRight;
|
|
63
|
+
}
|
|
64
|
+
nextLeft = Math.min(leftMax, Math.max(leftMin, nextLeft));
|
|
65
|
+
nextRight = pair - nextLeft;
|
|
66
|
+
return [Math.max(0, nextLeft), Math.max(0, nextRight)];
|
|
67
|
+
}
|