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.
Files changed (71) hide show
  1. package/README.md +22 -10
  2. package/package.json +2 -2
  3. package/src/alert/Alert.tsx +1 -1
  4. package/src/app/App.tsx +38 -18
  5. package/src/app/context.ts +32 -0
  6. package/src/button/Button.tsx +2 -2
  7. package/src/calendar/Calendar.tsx +4 -4
  8. package/src/chat/AgentPanel.tsx +15 -6
  9. package/src/chat/common/types.ts +27 -2
  10. package/src/chat/common/useAgentStream.ts +78 -12
  11. package/src/chat/common/utils.ts +13 -0
  12. package/src/chat/index.ts +5 -3
  13. package/src/chat/message-ui/ChatAgentMessage.tsx +1 -1
  14. package/src/chat/message-ui/ChatInput.tsx +1 -1
  15. package/src/chat/message-ui/ChatMarkdown.tsx +6 -6
  16. package/src/chat/message-ui/ChatMarkdownTable.tsx +1 -1
  17. package/src/chat/message-ui/ChatThinking.tsx +4 -3
  18. package/src/chat/message-ui/ChatUserMessage.tsx +0 -8
  19. package/src/chat/message-ui/MermaidBlock.tsx +11 -1
  20. package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +2 -1
  21. package/src/chat/tool-ui/CallAgentToolUI.tsx +3 -2
  22. package/src/chat/tool-ui/ChatToolCall.tsx +3 -2
  23. package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +2 -1
  24. package/src/chat/tool-ui/ReadSkillToolUI.tsx +3 -2
  25. package/src/chat/tool-ui/RunJsToolUI.tsx +3 -2
  26. package/src/chat/tool-ui/WebFetchToolUI.tsx +3 -2
  27. package/src/chat/tool-ui/registry.ts +24 -12
  28. package/src/checkbox/Checkbox.tsx +5 -4
  29. package/src/codeblock/CodeBlock.tsx +1 -0
  30. package/src/colorpicker/ColorPicker.tsx +694 -30
  31. package/src/colorpicker/color.ts +246 -0
  32. package/src/datepicker/DatePicker.tsx +15 -9
  33. package/src/desktop/DesktopHeader.tsx +1 -1
  34. package/src/desktop/DesktopIcon.tsx +2 -4
  35. package/src/desktop/DesktopWindow.tsx +19 -5
  36. package/src/desktop/MeadowDesktop.tsx +2 -2
  37. package/src/drawer/Drawer.tsx +8 -6
  38. package/src/dropdown/ContextMenu.tsx +3 -1
  39. package/src/dropdown/Dropdown.tsx +8 -6
  40. package/src/form/Form.tsx +2 -2
  41. package/src/form/FormItem.tsx +2 -2
  42. package/src/form/partials/FormItemHelps.tsx +2 -2
  43. package/src/form/variants/FieldColor.tsx +16 -51
  44. package/src/form/variants/FieldObject.tsx +2 -2
  45. package/src/form/variants/FieldRepeaterItem.tsx +4 -4
  46. package/src/form-layout/FormLayout.tsx +6 -6
  47. package/src/index.ts +25 -8
  48. package/src/input/Input.tsx +11 -10
  49. package/src/lib/sizes.ts +13 -3
  50. package/src/lib/surface.ts +2 -1
  51. package/src/menu/Menu.tsx +2 -2
  52. package/src/message/message.tsx +35 -2
  53. package/src/modal/Modal.tsx +49 -3
  54. package/src/pagination/Pagination.tsx +6 -5
  55. package/src/popconfirm/Popconfirm.tsx +1 -1
  56. package/src/popover/Popover.tsx +6 -8
  57. package/src/scroll/OverlayScroll.tsx +5 -3
  58. package/src/segmented/Segmented.tsx +6 -5
  59. package/src/select/Select.tsx +9 -6
  60. package/src/shimmer/Shimmer.tsx +17 -0
  61. package/src/skeleton/Skeleton.tsx +3 -3
  62. package/src/spin/Spin.tsx +8 -5
  63. package/src/splitter/Splitter.tsx +330 -0
  64. package/src/splitter/sizes.ts +67 -0
  65. package/src/styles.css +96 -27
  66. package/src/switch/Switch.tsx +6 -5
  67. package/src/table/Table.tsx +6 -7
  68. package/src/tag/Tag.tsx +2 -2
  69. package/src/theme.ts +23 -3
  70. package/src/timepicker/TimePicker.tsx +13 -11
  71. package/src/tooltip/Tooltip.tsx +6 -7
@@ -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 { useAppConfig } from "../app/App";
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 (antd default for Popconfirm). */
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
- /** antd `styles` — `root`/`body` map to content panel; `container` accepted as alias. */
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 app = useAppConfig();
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={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), 700);
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), 700);
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-[scrolling]/scroll:pointer-events-auto group-data-[scrolling]/scroll: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",
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, normalizeSize } from "../lib/sizes";
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 tok = getSizeTokens(size);
26
+ const resolvedSize = useControlSize(size);
27
+ const tok = getSizeTokens(resolvedSize);
27
28
  const itemPadX = Math.max(tok.paddingInline - 4, 6);
28
- const radius = controlRadiusVar(size);
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={normalizeSize(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(size)} - 4px))`,
53
+ height: `max(22px, calc(${controlHeightVar(resolvedSize)} - 4px))`,
53
54
  paddingLeft: itemPadX,
54
55
  paddingRight: itemPadX,
55
56
  fontSize: tok.fontSize,
@@ -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
- // antd interop: call sites often use (v: string) => void
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
- // Looser antd interop — many call sites type handlers as `(v: string) => void`.
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
- /** antd alias */
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(size)}
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 { type CSSProperties, type ReactNode } from "react";
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((_, i) => (
24
- <div key={i} className={cn("h-3 rounded bg-secondary", pulse, i === rows - 1 ? "w-2/3" : "w-full")} />
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
- {Array.from({ length: 9 }, (_, i) => (
107
- <span key={`c${i % 3}-r${Math.floor(i / 3)}`} className={cn("nonla-spin-matrix-cell", lit.has(i) && "is-on")} />
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={size} variant={variant} indicator={indicator} /> : null}
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={size} variant={variant} indicator={indicator} />
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
+ }