devnonla-ui 0.1.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.
- package/README.md +22 -10
- package/package.json +2 -2
- package/src/alert/Alert.tsx +1 -1
- package/src/app/App.tsx +38 -18
- package/src/app/context.ts +32 -0
- package/src/button/Button.tsx +2 -2
- package/src/calendar/Calendar.tsx +4 -4
- package/src/chat/AgentPanel.tsx +15 -6
- package/src/chat/common/types.ts +27 -2
- package/src/chat/common/useAgentStream.ts +78 -12
- package/src/chat/common/utils.ts +13 -0
- package/src/chat/index.ts +5 -3
- package/src/chat/message-ui/ChatAgentMessage.tsx +1 -1
- package/src/chat/message-ui/ChatInput.tsx +1 -1
- package/src/chat/message-ui/ChatMarkdown.tsx +6 -6
- package/src/chat/message-ui/ChatMarkdownTable.tsx +1 -1
- package/src/chat/message-ui/ChatThinking.tsx +4 -3
- package/src/chat/message-ui/ChatUserMessage.tsx +0 -8
- package/src/chat/message-ui/MermaidBlock.tsx +11 -1
- package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +2 -1
- package/src/chat/tool-ui/CallAgentToolUI.tsx +3 -2
- package/src/chat/tool-ui/ChatToolCall.tsx +3 -2
- package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +2 -1
- package/src/chat/tool-ui/ReadSkillToolUI.tsx +3 -2
- package/src/chat/tool-ui/RunJsToolUI.tsx +3 -2
- package/src/chat/tool-ui/WebFetchToolUI.tsx +3 -2
- package/src/chat/tool-ui/registry.ts +24 -12
- package/src/checkbox/Checkbox.tsx +5 -4
- package/src/codeblock/CodeBlock.tsx +1 -0
- package/src/colorpicker/ColorPicker.tsx +694 -30
- package/src/colorpicker/color.ts +246 -0
- package/src/datepicker/DatePicker.tsx +15 -9
- package/src/desktop/DesktopHeader.tsx +1 -1
- package/src/desktop/DesktopIcon.tsx +2 -4
- package/src/desktop/DesktopWindow.tsx +19 -5
- package/src/desktop/MeadowDesktop.tsx +2 -2
- package/src/drawer/Drawer.tsx +8 -6
- package/src/dropdown/ContextMenu.tsx +3 -1
- package/src/dropdown/Dropdown.tsx +8 -6
- package/src/form/Form.tsx +2 -2
- package/src/form/FormItem.tsx +2 -2
- package/src/form/partials/FormItemHelps.tsx +2 -2
- package/src/form/variants/FieldColor.tsx +16 -51
- package/src/form/variants/FieldObject.tsx +2 -2
- package/src/form/variants/FieldRepeaterItem.tsx +4 -4
- package/src/form-layout/FormLayout.tsx +6 -6
- package/src/index.ts +25 -8
- package/src/input/Input.tsx +11 -10
- package/src/lib/sizes.ts +13 -3
- package/src/lib/surface.ts +2 -1
- package/src/menu/Menu.tsx +2 -2
- package/src/message/message.tsx +35 -2
- package/src/modal/Modal.tsx +49 -3
- package/src/pagination/Pagination.tsx +6 -5
- package/src/popconfirm/Popconfirm.tsx +1 -1
- package/src/popover/Popover.tsx +6 -8
- package/src/scroll/OverlayScroll.tsx +5 -3
- package/src/segmented/Segmented.tsx +6 -5
- package/src/select/Select.tsx +9 -6
- package/src/shimmer/Shimmer.tsx +17 -0
- package/src/skeleton/Skeleton.tsx +3 -3
- package/src/spin/Spin.tsx +8 -5
- package/src/splitter/Splitter.tsx +330 -0
- package/src/splitter/sizes.ts +67 -0
- package/src/styles.css +96 -27
- package/src/switch/Switch.tsx +6 -5
- package/src/table/Table.tsx +6 -7
- package/src/tag/Tag.tsx +2 -2
- package/src/theme.ts +23 -3
- package/src/timepicker/TimePicker.tsx +13 -11
- package/src/tooltip/Tooltip.tsx +6 -7
package/src/popover/Popover.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
2
2
|
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { usePopupContainer } from "../app/context";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { type PopperPlacement, placementToRadix } from "../lib/placement";
|
|
6
6
|
import { glassOverlayClass } from "../lib/surface";
|
|
@@ -17,14 +17,14 @@ export type PopoverProps = {
|
|
|
17
17
|
mouseEnterDelay?: number;
|
|
18
18
|
mouseLeaveDelay?: number;
|
|
19
19
|
getPopupContainer?: () => HTMLElement;
|
|
20
|
-
/** Show arrow pointing at the trigger (
|
|
20
|
+
/** Show arrow pointing at the trigger (Popconfirm default). */
|
|
21
21
|
arrow?: boolean | { pointAtCenter?: boolean };
|
|
22
22
|
className?: string;
|
|
23
23
|
overlayClassName?: string;
|
|
24
24
|
/** Extra class on the content panel (padding / width). */
|
|
25
25
|
contentClassName?: string;
|
|
26
26
|
style?: CSSProperties;
|
|
27
|
-
/**
|
|
27
|
+
/** `styles` — `root`/`body` map to content panel; `container` accepted as alias. */
|
|
28
28
|
styles?: {
|
|
29
29
|
root?: CSSProperties;
|
|
30
30
|
body?: CSSProperties;
|
|
@@ -52,11 +52,11 @@ export function Popover({
|
|
|
52
52
|
style,
|
|
53
53
|
styles,
|
|
54
54
|
}: PopoverProps) {
|
|
55
|
+
const hover = trigger === "hover";
|
|
55
56
|
const showArrow = Boolean(arrow);
|
|
56
57
|
const panelStyle = { ...style, ...styles?.root, ...styles?.content, ...styles?.container, ...styles?.body };
|
|
57
|
-
const
|
|
58
|
+
const getContainer = usePopupContainer(getPopupContainer);
|
|
58
59
|
const { side, align } = placementToRadix(placement);
|
|
59
|
-
const hover = trigger === "hover";
|
|
60
60
|
const [innerOpen, setInnerOpen] = useState(defaultOpen ?? false);
|
|
61
61
|
const open = openProp ?? innerOpen;
|
|
62
62
|
const enterTimer = useRef<number>(0);
|
|
@@ -83,8 +83,6 @@ export function Popover({
|
|
|
83
83
|
leaveTimer.current = window.setTimeout(() => setOpen(false), mouseLeaveDelay * 1000);
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
-
const container = getPopupContainer ?? app.getPopupContainer;
|
|
87
|
-
|
|
88
86
|
return (
|
|
89
87
|
<PopoverPrimitive.Root
|
|
90
88
|
open={open}
|
|
@@ -96,7 +94,7 @@ export function Popover({
|
|
|
96
94
|
<PopoverPrimitive.Trigger asChild onMouseEnter={onEnter} onMouseLeave={onLeave}>
|
|
97
95
|
{children}
|
|
98
96
|
</PopoverPrimitive.Trigger>
|
|
99
|
-
<PopoverPrimitive.Portal container={
|
|
97
|
+
<PopoverPrimitive.Portal container={getContainer()}>
|
|
100
98
|
<PopoverPrimitive.Content
|
|
101
99
|
side={side}
|
|
102
100
|
align={align}
|
|
@@ -3,6 +3,8 @@ import { cn } from "../lib/cn";
|
|
|
3
3
|
|
|
4
4
|
export type OverlayScrollVisibility = "hover" | "always";
|
|
5
5
|
|
|
6
|
+
const HIDE_MS = 1000;
|
|
7
|
+
|
|
6
8
|
export type OverlayScrollProps = {
|
|
7
9
|
/** `hover` shows the thumb on hover / while scrolling. `always` keeps it visible. */
|
|
8
10
|
visibility?: OverlayScrollVisibility;
|
|
@@ -75,7 +77,7 @@ export function OverlayScroll({ visibility = "hover", className, innerClassName,
|
|
|
75
77
|
if (visibility === "hover" && !dragging) {
|
|
76
78
|
setScrolling(true);
|
|
77
79
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
78
|
-
hideTimer.current = setTimeout(() => setScrolling(false),
|
|
80
|
+
hideTimer.current = setTimeout(() => setScrolling(false), HIDE_MS);
|
|
79
81
|
}
|
|
80
82
|
onScroll?.(e);
|
|
81
83
|
};
|
|
@@ -109,7 +111,7 @@ export function OverlayScroll({ visibility = "hover", className, innerClassName,
|
|
|
109
111
|
window.removeEventListener("pointercancel", onUp, true);
|
|
110
112
|
setDragging(false);
|
|
111
113
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
112
|
-
hideTimer.current = setTimeout(() => setScrolling(false),
|
|
114
|
+
hideTimer.current = setTimeout(() => setScrolling(false), HIDE_MS);
|
|
113
115
|
};
|
|
114
116
|
window.addEventListener("pointermove", onMove, true);
|
|
115
117
|
window.addEventListener("pointerup", onUp, true);
|
|
@@ -131,7 +133,7 @@ export function OverlayScroll({ visibility = "hover", className, innerClassName,
|
|
|
131
133
|
"nonla-overlay-thumb absolute z-20 rounded-full transition-opacity duration-150",
|
|
132
134
|
visibility === "always" || dragging
|
|
133
135
|
? "pointer-events-auto opacity-100"
|
|
134
|
-
: "pointer-events-none opacity-0 group-hover/scroll:pointer-events-auto group-hover/scroll:opacity-100 group-data-
|
|
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",
|
|
135
137
|
)}
|
|
136
138
|
style={{ right: 2, width: "var(--nonla-scrollbar-size)", height: thumb.height, transform: `translateY(${thumb.top}px)` }}
|
|
137
139
|
/>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
|
-
import { type ControlSize, controlHeightVar, controlRadiusVar, getSizeTokens,
|
|
3
|
+
import { type ControlSize, controlHeightVar, controlRadiusVar, getSizeTokens, useControlSize } from "../lib/sizes";
|
|
4
4
|
|
|
5
5
|
export type SegmentedOption<V extends string | number = string | number> = V | { label: ReactNode; value: V; disabled?: boolean; icon?: ReactNode };
|
|
6
6
|
|
|
@@ -23,16 +23,17 @@ function norm<V extends string | number>(opt: SegmentedOption<V>): { label: Reac
|
|
|
23
23
|
export function Segmented<V extends string | number = string | number>({ options, value, defaultValue, onChange, size, block, disabled, className }: SegmentedProps<V>) {
|
|
24
24
|
const items = options.map((o) => norm(o));
|
|
25
25
|
const current = value ?? defaultValue ?? items[0]?.value;
|
|
26
|
-
const
|
|
26
|
+
const resolvedSize = useControlSize(size);
|
|
27
|
+
const tok = getSizeTokens(resolvedSize);
|
|
27
28
|
const itemPadX = Math.max(tok.paddingInline - 4, 6);
|
|
28
|
-
const radius = controlRadiusVar(
|
|
29
|
+
const radius = controlRadiusVar(resolvedSize);
|
|
29
30
|
|
|
30
31
|
return (
|
|
31
32
|
<div
|
|
32
33
|
className={cn("inline-flex w-fit max-w-full shrink-0 items-center gap-0.5 rounded-lg bg-brand-50 p-0.5", block && "flex w-full", className)}
|
|
33
34
|
role="tablist"
|
|
34
35
|
style={{ borderRadius: radius }}
|
|
35
|
-
data-size={
|
|
36
|
+
data-size={resolvedSize}
|
|
36
37
|
>
|
|
37
38
|
{items.map((item) => {
|
|
38
39
|
const active = item.value === current;
|
|
@@ -49,7 +50,7 @@ export function Segmented<V extends string | number = string | number>({ options
|
|
|
49
50
|
active ? "bg-brand-200 text-foreground" : "bg-transparent text-foreground hover:bg-brand-100",
|
|
50
51
|
)}
|
|
51
52
|
style={{
|
|
52
|
-
height: `max(22px, calc(${controlHeightVar(
|
|
53
|
+
height: `max(22px, calc(${controlHeightVar(resolvedSize)} - 4px))`,
|
|
53
54
|
paddingLeft: itemPadX,
|
|
54
55
|
paddingRight: itemPadX,
|
|
55
56
|
fontSize: tok.fontSize,
|
package/src/select/Select.tsx
CHANGED
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
useRef,
|
|
12
12
|
useState,
|
|
13
13
|
} from "react";
|
|
14
|
+
import { usePopupContainer } from "../app/context";
|
|
14
15
|
import { cn } from "../lib/cn";
|
|
15
|
-
import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlStatusClass } from "../lib/sizes";
|
|
16
|
+
import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlStatusClass, useControlSize } from "../lib/sizes";
|
|
16
17
|
import { glassOverlayClass } from "../lib/surface";
|
|
17
18
|
|
|
18
19
|
export type SelectValue = string | number;
|
|
@@ -26,10 +27,10 @@ export type SelectOptionConfig = {
|
|
|
26
27
|
export type SelectProps = {
|
|
27
28
|
value?: SelectValue | null;
|
|
28
29
|
defaultValue?: SelectValue | null;
|
|
29
|
-
//
|
|
30
|
+
// Call sites often use (v: string) => void
|
|
30
31
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
32
|
onChange?: (value: any) => void;
|
|
32
|
-
//
|
|
33
|
+
// Many call sites type handlers as `(v: string) => void`.
|
|
33
34
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
35
|
options?: SelectOptionConfig[];
|
|
35
36
|
placeholder?: string;
|
|
@@ -42,7 +43,7 @@ export type SelectProps = {
|
|
|
42
43
|
className?: string;
|
|
43
44
|
popupClassName?: string;
|
|
44
45
|
children?: ReactNode;
|
|
45
|
-
/**
|
|
46
|
+
/** Alias of `onChange` for a single pick. */
|
|
46
47
|
onSelect?: (value: SelectValue) => void;
|
|
47
48
|
};
|
|
48
49
|
|
|
@@ -104,6 +105,8 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
|
|
|
104
105
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
105
106
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
106
107
|
const listRef = useRef<HTMLDivElement>(null);
|
|
108
|
+
const resolvedSize = useControlSize(size);
|
|
109
|
+
const portal = usePopupContainer()?.();
|
|
107
110
|
|
|
108
111
|
const searchable = Boolean(showSearch);
|
|
109
112
|
const filterProp =
|
|
@@ -219,7 +222,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
|
|
|
219
222
|
controlStatusClass(status),
|
|
220
223
|
className,
|
|
221
224
|
)}
|
|
222
|
-
style={controlFieldStyle(
|
|
225
|
+
style={controlFieldStyle(resolvedSize)}
|
|
223
226
|
onKeyDown={onTriggerKey}
|
|
224
227
|
>
|
|
225
228
|
<span className={cn("min-w-0 flex-1 truncate", selected == null || selected === "" ? "text-quaternary-foreground" : "")}>
|
|
@@ -244,7 +247,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select
|
|
|
244
247
|
<Chevron />
|
|
245
248
|
</button>
|
|
246
249
|
</PopoverPrimitive.Trigger>
|
|
247
|
-
<PopoverPrimitive.Portal>
|
|
250
|
+
<PopoverPrimitive.Portal container={portal}>
|
|
248
251
|
<PopoverPrimitive.Content
|
|
249
252
|
align="start"
|
|
250
253
|
sideOffset={4}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type ShimmerProps = HTMLAttributes<HTMLSpanElement> & {
|
|
5
|
+
/** When false, children render as static text. Default true. */
|
|
6
|
+
active?: boolean;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Sweep highlight on live status text (Thinking, Fetching, …). */
|
|
11
|
+
export function Shimmer({ active = true, className, children, ...rest }: ShimmerProps) {
|
|
12
|
+
return (
|
|
13
|
+
<span className={cn(active && "nonla-shimmer", className)} aria-busy={active || undefined} {...rest}>
|
|
14
|
+
{children}
|
|
15
|
+
</span>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
|
|
4
4
|
export type SkeletonProps = {
|
|
@@ -20,8 +20,8 @@ function SkeletonRoot({ active = true, loading = true, paragraph = true, title =
|
|
|
20
20
|
{avatar ? <div className={cn("size-10 shrink-0 rounded-full bg-secondary", pulse)} /> : null}
|
|
21
21
|
<div className="flex-1 space-y-2">
|
|
22
22
|
{title ? <div className={cn("h-4 w-1/3 rounded bg-secondary", pulse)} /> : null}
|
|
23
|
-
{Array.from({ length: rows }).map((
|
|
24
|
-
<div key={
|
|
23
|
+
{Array.from({ length: rows }, (_, i) => `sk-${i}`).map((key, i) => (
|
|
24
|
+
<div key={key} className={cn("h-3 rounded bg-secondary", pulse, i === rows - 1 ? "w-2/3" : "w-full")} />
|
|
25
25
|
))}
|
|
26
26
|
</div>
|
|
27
27
|
</div>
|
package/src/spin/Spin.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ReactNode, useEffect, useState } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
|
-
import { type ControlSize, getSizeTokens } from "../lib/sizes";
|
|
3
|
+
import { type ControlSize, getSizeTokens, useControlSize } from "../lib/sizes";
|
|
4
4
|
|
|
5
5
|
export type SpinVariant = "default" | "agent" | "subAgent";
|
|
6
6
|
|
|
@@ -35,6 +35,8 @@ type Cell = number; // 0..8 on a 3×3 grid
|
|
|
35
35
|
/** Snake = [head, mid, tail] — always 3 orthogonally adjacent cells. */
|
|
36
36
|
type Snake = [Cell, Cell, Cell];
|
|
37
37
|
|
|
38
|
+
const MATRIX_CELLS = [0, 1, 2, 3, 4, 5, 6, 7, 8] as const;
|
|
39
|
+
|
|
38
40
|
const NEIGHBORS: Record<number, number[]> = {
|
|
39
41
|
0: [1, 3],
|
|
40
42
|
1: [0, 2, 4],
|
|
@@ -103,8 +105,8 @@ function MatrixSnake({ size, tickMs = 220 }: { size: ControlSize | undefined; ti
|
|
|
103
105
|
}}
|
|
104
106
|
aria-hidden
|
|
105
107
|
>
|
|
106
|
-
{
|
|
107
|
-
<span key={
|
|
108
|
+
{MATRIX_CELLS.map((cell) => (
|
|
109
|
+
<span key={cell} className={cn("nonla-spin-matrix-cell", lit.has(cell) && "is-on")} />
|
|
108
110
|
))}
|
|
109
111
|
</span>
|
|
110
112
|
);
|
|
@@ -138,10 +140,11 @@ function Indicator({
|
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
export function Spin({ spinning = true, tip, size, variant = "default", indicator, children, className }: SpinProps) {
|
|
143
|
+
const resolvedSize = useControlSize(size);
|
|
141
144
|
if (children == null) {
|
|
142
145
|
return (
|
|
143
146
|
<div className={cn("inline-flex flex-col items-center gap-2", className)} role={spinning ? "status" : undefined} aria-live={spinning ? "polite" : undefined}>
|
|
144
|
-
{spinning ? <Indicator size={
|
|
147
|
+
{spinning ? <Indicator size={resolvedSize} variant={variant} indicator={indicator} /> : null}
|
|
145
148
|
{tip ? <span className="text-xs text-muted-foreground">{tip}</span> : null}
|
|
146
149
|
</div>
|
|
147
150
|
);
|
|
@@ -151,7 +154,7 @@ export function Spin({ spinning = true, tip, size, variant = "default", indicato
|
|
|
151
154
|
{children}
|
|
152
155
|
{spinning ? (
|
|
153
156
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background/50" role="status" aria-live="polite">
|
|
154
|
-
<Indicator size={
|
|
157
|
+
<Indicator size={resolvedSize} variant={variant} indicator={indicator} />
|
|
155
158
|
{tip ? <span className="text-xs text-muted-foreground">{tip}</span> : null}
|
|
156
159
|
</div>
|
|
157
160
|
) : null}
|
|
@@ -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 };
|
|
@@ -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
|
+
}
|