@startsimpli/ui 0.4.14 → 0.4.16

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 (78) hide show
  1. package/README.md +457 -398
  2. package/package.json +18 -13
  3. package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
  4. package/src/components/__tests__/chat.test.tsx +129 -0
  5. package/src/components/__tests__/meetings-list.test.tsx +114 -0
  6. package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
  7. package/src/components/__tests__/workspace.test.tsx +106 -0
  8. package/src/components/account/__tests__/account.test.tsx +5 -32
  9. package/src/components/account/change-password-form.tsx +1 -28
  10. package/src/components/calendar/calendar-view.tsx +31 -0
  11. package/src/components/calendar/index.ts +7 -0
  12. package/src/components/calendar/meetings-list.tsx +202 -0
  13. package/src/components/calendar/upcoming-meetings.tsx +5 -5
  14. package/src/components/chat/ChatComposer.tsx +113 -0
  15. package/src/components/chat/ChatMessage.tsx +81 -0
  16. package/src/components/chat/ChatThread.tsx +57 -0
  17. package/src/components/chat/index.ts +12 -0
  18. package/src/components/chat/types.ts +20 -0
  19. package/src/components/index.ts +13 -0
  20. package/src/components/slide-deck/SlideCanvas.tsx +68 -0
  21. package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
  22. package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
  23. package/src/components/slide-deck/index.ts +7 -0
  24. package/src/components/slide-deck/types.ts +18 -0
  25. package/src/components/team/DomainClaimCard.tsx +170 -0
  26. package/src/components/team/InviteMemberDialog.tsx +182 -0
  27. package/src/components/team/LeaveTeamDialog.tsx +130 -0
  28. package/src/components/team/MembersTable.tsx +138 -0
  29. package/src/components/team/OrgSwitcher.tsx +68 -0
  30. package/src/components/team/PendingInvitationCallout.tsx +106 -0
  31. package/src/components/team/RoleSelector.tsx +68 -0
  32. package/src/components/team/__tests__/team-components.test.tsx +352 -0
  33. package/src/components/team/__tests__/team-settings-page.test.tsx +146 -0
  34. package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
  35. package/src/components/team/index.ts +62 -0
  36. package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
  37. package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
  38. package/src/components/team/members-table-default-class-names.ts +39 -0
  39. package/src/components/team/org-switcher-default-class-names.ts +13 -0
  40. package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
  41. package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
  42. package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
  43. package/src/components/team/pages/index.ts +33 -0
  44. package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
  45. package/src/components/team/pages/types.ts +135 -0
  46. package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
  47. package/src/components/team/role-selector-default-class-names.ts +11 -0
  48. package/src/components/team/types.ts +97 -0
  49. package/src/components/workflows/ExecNodeDetails.tsx +83 -0
  50. package/src/components/workflows/ExecutionTimeline.tsx +146 -0
  51. package/src/components/workflows/NodeInspector.tsx +257 -0
  52. package/src/components/workflows/NodePalette.tsx +119 -0
  53. package/src/components/workflows/WorkflowCanvas.tsx +113 -0
  54. package/src/components/workflows/WorkflowEdge.tsx +65 -0
  55. package/src/components/workflows/WorkflowEditor.tsx +130 -0
  56. package/src/components/workflows/WorkflowNode.tsx +198 -0
  57. package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
  58. package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
  59. package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
  60. package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
  61. package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
  62. package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
  63. package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
  64. package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
  65. package/src/components/workflows/__tests__/serialization.test.ts +278 -0
  66. package/src/components/workflows/exec-status.ts +90 -0
  67. package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
  68. package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
  69. package/src/components/workflows/index.ts +78 -0
  70. package/src/components/workflows/layout/auto-layout.ts +142 -0
  71. package/src/components/workflows/node-icons.ts +31 -0
  72. package/src/components/workflows/serialization.ts +171 -0
  73. package/src/components/workflows/theme/categories.ts +96 -0
  74. package/src/components/workflows/types.ts +231 -0
  75. package/src/components/workflows/workflows.css +29 -0
  76. package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
  77. package/src/components/workspace/SplitPane.tsx +174 -0
  78. package/src/components/workspace/index.ts +4 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Generic workflow-graph types — mirrors the StartSimpli backend
3
+ * (apps.workflows) n8n-style JSON graph definition.
4
+ *
5
+ * The graph is "behavior as data": a directed graph of typed nodes
6
+ * connected by edges. The shapes here are intentionally generic — node
7
+ * `type` is just a slug and `category` is a free string token resolved
8
+ * against the category theme map (theme/categories.ts).
9
+ *
10
+ * NOTE: `connections` is the LOCKED backend persistence format. See
11
+ * serialization.ts for the canonical shape and round-trip guarantees.
12
+ */
13
+
14
+ // ── Status enums (match backend TextChoices verbatim) ──
15
+
16
+ /** NodeExecution.Status — per-node runtime status (+ "idle" for editor). */
17
+ export type NodeStatus =
18
+ | 'idle'
19
+ | 'pending'
20
+ | 'running'
21
+ | 'success'
22
+ | 'error'
23
+ | 'skipped';
24
+
25
+ /** WorkflowExecution.Status — overall run status. */
26
+ export type RunStatus =
27
+ | 'pending'
28
+ | 'running'
29
+ | 'completed'
30
+ | 'failed'
31
+ | 'cancelled'
32
+ | 'waiting';
33
+
34
+ // ── Graph definition ──
35
+
36
+ /**
37
+ * A single node in a workflow graph.
38
+ *
39
+ * Matches a backend `Workflow.nodes[]` entry:
40
+ * `{ id, name, type, parameters, ... }`. Extra backend fields
41
+ * (`disabled`, `continue_on_fail`) are preserved opaquely.
42
+ */
43
+ export interface WfNode {
44
+ /** Stable node id — referenced by connections. */
45
+ id: string;
46
+ /** Human-readable name. */
47
+ name: string;
48
+ /** Node type slug, e.g. "condition.if", "trigger.event". */
49
+ type: string;
50
+ /** Node parameters (executor config). */
51
+ parameters?: Record<string, unknown>;
52
+ /**
53
+ * Optional explicit canvas position. When present, auto-layout
54
+ * preserves it instead of computing one.
55
+ */
56
+ position?: WfPosition;
57
+ /** Whether the node is disabled (skipped at runtime). */
58
+ disabled?: boolean;
59
+ /** Whether downstream traversal continues if this node fails. */
60
+ continueOnFail?: boolean;
61
+ /** Any additional opaque node fields. */
62
+ [key: string]: unknown;
63
+ }
64
+
65
+ export interface WfPosition {
66
+ x: number;
67
+ y: number;
68
+ }
69
+
70
+ /**
71
+ * A logical edge in the graph.
72
+ *
73
+ * `sourceOutput` is the source node's output index (n8n `main[i]`):
74
+ * 0 for a single output, 0/1 for if/else, 0..N-1 for switch.
75
+ * `targetInput` is the target node's input index (the `index` field
76
+ * stored on each backend connection — almost always 0).
77
+ */
78
+ export interface WfEdge {
79
+ /** Source node id. */
80
+ source: string;
81
+ /** Target node id. */
82
+ target: string;
83
+ /** Source output index (default 0). */
84
+ sourceOutput?: number;
85
+ /** Target input index (default 0). */
86
+ targetInput?: number;
87
+ /** Connection type — n8n uses "main"; default "main". */
88
+ type?: string;
89
+ }
90
+
91
+ /**
92
+ * Definition of a node type (catalog entry). Mirrors backend NodeType.
93
+ */
94
+ export interface NodeTypeDef {
95
+ /** Unique slug, e.g. "condition.if". */
96
+ slug: string;
97
+ /** Human-readable name. */
98
+ name: string;
99
+ /** Category token (resolved via theme/categories.ts). */
100
+ category: string;
101
+ /** Number of outputs (1 for most, 2 for if/else, N for switch). */
102
+ outputCount?: number;
103
+ /** Labels for each output, e.g. ["true", "false"]. */
104
+ outputLabels?: string[];
105
+ /** Optional description. */
106
+ description?: string;
107
+ /**
108
+ * JSON-Schema (object schema) describing the node's `parameters`. Drives the
109
+ * editor's NodeInspector form. Intentionally typed loosely — only the subset
110
+ * the inspector understands (`type`, `properties`, `title`, `enum`,
111
+ * `description`) is consumed; unknown keywords are ignored.
112
+ */
113
+ parameterSchema?: JsonSchema;
114
+ }
115
+
116
+ /** Minimal JSON-Schema subset consumed by the NodeInspector form. */
117
+ export interface JsonSchema {
118
+ type?: 'object' | 'string' | 'number' | 'integer' | 'boolean' | 'array';
119
+ title?: string;
120
+ description?: string;
121
+ /** For object schemas: per-property sub-schemas. */
122
+ properties?: Record<string, JsonSchema>;
123
+ /** Required property names (object schemas). */
124
+ required?: string[];
125
+ /** Enumerated allowed values (renders a select). */
126
+ enum?: (string | number)[];
127
+ /** Default value. */
128
+ default?: unknown;
129
+ }
130
+
131
+ /**
132
+ * A complete workflow graph in the editor's working shape.
133
+ *
134
+ * `connections` (the backend LOCKED format) is intentionally NOT stored
135
+ * here — instead edges are kept as a flat list and converted to/from the
136
+ * nested backend shape by serialization.ts.
137
+ */
138
+ export interface WorkflowGraph {
139
+ /** Optional workflow id. */
140
+ id?: string;
141
+ /** Optional workflow name. */
142
+ name?: string;
143
+ /** Graph nodes. */
144
+ nodes: WfNode[];
145
+ /** Graph edges (flat). */
146
+ edges: WfEdge[];
147
+ }
148
+
149
+ // ── Backend persistence (LOCKED n8n connections shape) ──
150
+
151
+ /** A single connection target: `{ node, type, index }`. */
152
+ export interface BackendConnection {
153
+ /** Target node id. */
154
+ node: string;
155
+ /** Connection type — "main". */
156
+ type: string;
157
+ /** Target input index. */
158
+ index: number;
159
+ }
160
+
161
+ /**
162
+ * The LOCKED backend connections map:
163
+ *
164
+ * ```json
165
+ * {
166
+ * "sourceNodeId": {
167
+ * "main": [
168
+ * [ { "node": "targetId", "type": "main", "index": 0 } ]
169
+ * ]
170
+ * }
171
+ * }
172
+ * ```
173
+ *
174
+ * Outer array index = source output index. Each inner array is the list
175
+ * of parallel targets fanning out from that output.
176
+ */
177
+ export type BackendConnections = Record<
178
+ string,
179
+ Record<string, BackendConnection[][]>
180
+ >;
181
+
182
+ /** Backend node shape (snake_case fields as persisted). */
183
+ export interface BackendNode {
184
+ id: string;
185
+ name: string;
186
+ type: string;
187
+ parameters?: Record<string, unknown>;
188
+ position?: WfPosition;
189
+ disabled?: boolean;
190
+ continue_on_fail?: boolean;
191
+ [key: string]: unknown;
192
+ }
193
+
194
+ // ── Execution views (read-only overlays for run visualization) ──
195
+
196
+ /** Per-node execution status overlay. Mirrors backend NodeExecution. */
197
+ export interface NodeExecView {
198
+ nodeId: string;
199
+ status: NodeStatus;
200
+ /**
201
+ * Display name of the node. Optional because the bare status overlay
202
+ * (keyed by node id) may be merged with the graph's node names at the
203
+ * call site; the execution monitor renders it when present.
204
+ */
205
+ name?: string;
206
+ /** Node type slug (e.g. "condition.if"), shown in the detail panel. */
207
+ type?: string;
208
+ /**
209
+ * Position of this node in the run's execution sequence. The timeline
210
+ * renders rows sorted by this value (ascending).
211
+ */
212
+ executionOrder?: number;
213
+ startedAt?: string | null;
214
+ completedAt?: string | null;
215
+ executionTimeMs?: number | null;
216
+ error?: string;
217
+ /** Input payload fed to the node (pretty-printed in the detail panel). */
218
+ inputData?: Record<string, unknown>;
219
+ outputData?: Record<string, unknown>;
220
+ }
221
+
222
+ /** Whole-run execution overlay. Mirrors backend WorkflowExecution. */
223
+ export interface WfExecutionView {
224
+ executionId?: string;
225
+ status: RunStatus;
226
+ startedAt?: string | null;
227
+ completedAt?: string | null;
228
+ errorMessage?: string;
229
+ /** Per-node status keyed by node id. */
230
+ nodes: Record<string, NodeExecView>;
231
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @startsimpli/ui/workflows/styles
3
+ *
4
+ * Scoped stylesheet for the workflow editor/viewer. Re-exports the
5
+ * @xyflow/react base stylesheet (required for the canvas to render its
6
+ * pane, controls, handles, and edges) and adds the animated-edge keyframes
7
+ * used by WorkflowEdge for live runs. Consumers import this once, e.g.:
8
+ *
9
+ * import '@startsimpli/ui/workflows/styles';
10
+ */
11
+
12
+ @import '@xyflow/react/dist/style.css';
13
+
14
+ /* Animated flow for edges whose source node is running/succeeded. */
15
+ .workflow-edge--animated {
16
+ stroke-dasharray: 6 4;
17
+ animation: workflow-edge-dash 0.6s linear infinite;
18
+ }
19
+
20
+ @keyframes workflow-edge-dash {
21
+ to {
22
+ stroke-dashoffset: -10;
23
+ }
24
+ }
25
+
26
+ /* Keep the canvas filling its container. */
27
+ .workflow-canvas {
28
+ min-height: 0;
29
+ }
@@ -0,0 +1,187 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
5
+ import { cn } from '../../lib/utils'
6
+ import { SplitPane } from './SplitPane'
7
+
8
+ export type WorkspaceLayout = 'split' | 'stack' | 'auto'
9
+
10
+ export interface DualPaneWorkspaceProps {
11
+ left: React.ReactNode
12
+ right: React.ReactNode
13
+ /** Optional toolbar rendered above both panes (full width). */
14
+ toolbar?: React.ReactNode
15
+ leftLabel?: string
16
+ rightLabel?: string
17
+ /** Initial size of the left pane as a percentage. Default 42. */
18
+ initialLeftSize?: number
19
+ minLeftSize?: number
20
+ maxLeftSize?: number
21
+ /** Persist the split position under this key. */
22
+ storageKey?: string
23
+ /**
24
+ * `split` = resizable side-by-side, `stack` = vertically stacked,
25
+ * `auto` = stack below `stackBreakpoint`, split above. Default `auto`.
26
+ */
27
+ layout?: WorkspaceLayout
28
+ /** Pixel width below which `auto` stacks. Default 768. */
29
+ stackBreakpoint?: number
30
+ /** Show collapse/expand controls for each pane (split layout only). */
31
+ collapsible?: boolean
32
+ className?: string
33
+ }
34
+
35
+ function useResolvedLayout(layout: WorkspaceLayout, stackBreakpoint: number): 'split' | 'stack' {
36
+ const [isNarrow, setIsNarrow] = React.useState(false)
37
+ React.useEffect(() => {
38
+ if (layout !== 'auto' || typeof window === 'undefined') return
39
+ const mql = window.matchMedia(`(max-width: ${stackBreakpoint - 1}px)`)
40
+ const update = () => setIsNarrow(mql.matches)
41
+ update()
42
+ mql.addEventListener?.('change', update)
43
+ return () => mql.removeEventListener?.('change', update)
44
+ }, [layout, stackBreakpoint])
45
+
46
+ if (layout === 'split') return 'split'
47
+ if (layout === 'stack') return 'stack'
48
+ return isNarrow ? 'stack' : 'split'
49
+ }
50
+
51
+ interface PaneHeaderProps {
52
+ label?: string
53
+ side: 'left' | 'right'
54
+ collapsible?: boolean
55
+ collapsed: boolean
56
+ onToggle: () => void
57
+ }
58
+
59
+ function PaneHeader({ label, side, collapsible, collapsed, onToggle }: PaneHeaderProps) {
60
+ if (!label && !collapsible) return null
61
+ const CollapseIcon = side === 'left' ? PanelLeftClose : PanelRightClose
62
+ const ExpandIcon = side === 'left' ? PanelLeftOpen : PanelRightOpen
63
+ const Icon = collapsed ? ExpandIcon : CollapseIcon
64
+ const verb = collapsed ? 'Expand' : 'Collapse'
65
+ return (
66
+ <div className="flex h-9 shrink-0 items-center justify-between border-b border-border bg-muted/30 px-3">
67
+ {label ? (
68
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
69
+ ) : (
70
+ <span />
71
+ )}
72
+ {collapsible && (
73
+ <button
74
+ type="button"
75
+ onClick={onToggle}
76
+ aria-label={`${verb} ${label ?? side} pane`}
77
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
78
+ >
79
+ <Icon className="h-4 w-4" aria-hidden="true" />
80
+ </button>
81
+ )}
82
+ </div>
83
+ )
84
+ }
85
+
86
+ /**
87
+ * Opinionated two-pane workspace shell built on {@link SplitPane}: an optional
88
+ * full-width toolbar, two labelled/collapsible panes side-by-side, and
89
+ * automatic vertical stacking on narrow viewports. Fully app-agnostic — used by
90
+ * present-web for chat | slides, but free of any presentation-specific logic.
91
+ */
92
+ export function DualPaneWorkspace({
93
+ left,
94
+ right,
95
+ toolbar,
96
+ leftLabel,
97
+ rightLabel,
98
+ initialLeftSize = 42,
99
+ minLeftSize = 24,
100
+ maxLeftSize = 72,
101
+ storageKey,
102
+ layout = 'auto',
103
+ stackBreakpoint = 768,
104
+ collapsible = false,
105
+ className,
106
+ }: DualPaneWorkspaceProps) {
107
+ const resolved = useResolvedLayout(layout, stackBreakpoint)
108
+ const [collapsed, setCollapsed] = React.useState<null | 'left' | 'right'>(null)
109
+
110
+ const leftHeader = (
111
+ <PaneHeader
112
+ label={leftLabel}
113
+ side="left"
114
+ collapsible={collapsible}
115
+ collapsed={collapsed === 'left'}
116
+ onToggle={() => setCollapsed((c) => (c === 'left' ? null : 'left'))}
117
+ />
118
+ )
119
+ const rightHeader = (
120
+ <PaneHeader
121
+ label={rightLabel}
122
+ side="right"
123
+ collapsible={collapsible}
124
+ collapsed={collapsed === 'right'}
125
+ onToggle={() => setCollapsed((c) => (c === 'right' ? null : 'right'))}
126
+ />
127
+ )
128
+
129
+ const leftPane = (
130
+ <section className="flex h-full min-h-0 flex-col" aria-label={leftLabel}>
131
+ {leftHeader}
132
+ {collapsed !== 'left' && <div className="min-h-0 flex-1 overflow-hidden">{left}</div>}
133
+ </section>
134
+ )
135
+ const rightPane = (
136
+ <section className="flex h-full min-h-0 flex-col" aria-label={rightLabel}>
137
+ {rightHeader}
138
+ {collapsed !== 'right' && <div className="min-h-0 flex-1 overflow-hidden">{right}</div>}
139
+ </section>
140
+ )
141
+
142
+ // When one pane is collapsed, the other takes the full width (no resizer).
143
+ let body: React.ReactNode
144
+ if (resolved === 'stack') {
145
+ body = (
146
+ <div className="flex min-h-0 flex-1 flex-col divide-y divide-border" data-layout="stack">
147
+ <div className="min-h-0 flex-1 overflow-hidden">{leftPane}</div>
148
+ <div className="min-h-0 flex-1 overflow-hidden">{rightPane}</div>
149
+ </div>
150
+ )
151
+ } else if (collapsed === 'left') {
152
+ body = (
153
+ <div className="flex min-h-0 flex-1 flex-row" data-layout="split" data-collapsed="left">
154
+ <div className="shrink-0">{leftHeader}</div>
155
+ <div className="min-h-0 flex-1 overflow-hidden">{rightPane}</div>
156
+ </div>
157
+ )
158
+ } else if (collapsed === 'right') {
159
+ body = (
160
+ <div className="flex min-h-0 flex-1 flex-row" data-layout="split" data-collapsed="right">
161
+ <div className="min-h-0 flex-1 overflow-hidden">{leftPane}</div>
162
+ <div className="shrink-0">{rightHeader}</div>
163
+ </div>
164
+ )
165
+ } else {
166
+ body = (
167
+ <div className="min-h-0 flex-1" data-layout="split">
168
+ <SplitPane
169
+ first={leftPane}
170
+ second={rightPane}
171
+ initialSize={initialLeftSize}
172
+ minSize={minLeftSize}
173
+ maxSize={maxLeftSize}
174
+ storageKey={storageKey}
175
+ aria-label="Resize workspace panes"
176
+ />
177
+ </div>
178
+ )
179
+ }
180
+
181
+ return (
182
+ <div className={cn('flex h-full min-h-0 w-full flex-col', className)}>
183
+ {toolbar && <div className="shrink-0 border-b border-border">{toolbar}</div>}
184
+ {body}
185
+ </div>
186
+ )
187
+ }
@@ -0,0 +1,174 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ export interface SplitPaneProps {
7
+ /** Content of the first (left / top) pane. */
8
+ first: React.ReactNode
9
+ /** Content of the second (right / bottom) pane. */
10
+ second: React.ReactNode
11
+ /** Split direction. `vertical` = side-by-side (a vertical divider). Default `vertical`. */
12
+ orientation?: 'vertical' | 'horizontal'
13
+ /** Initial size of the first pane, as a percentage (0-100). Default 50. */
14
+ initialSize?: number
15
+ /** Minimum size of the first pane as a percentage. Default 20. */
16
+ minSize?: number
17
+ /** Maximum size of the first pane as a percentage. Default 80. */
18
+ maxSize?: number
19
+ /** Keyboard nudge / step size in percentage points. Default 3. */
20
+ step?: number
21
+ /** Persist the size under this key in localStorage. */
22
+ storageKey?: string
23
+ /** Notified with the new first-pane percentage whenever it changes. */
24
+ onResize?: (size: number) => void
25
+ className?: string
26
+ 'aria-label'?: string
27
+ }
28
+
29
+ function clamp(v: number, min: number, max: number) {
30
+ return Math.min(max, Math.max(min, v))
31
+ }
32
+
33
+ /**
34
+ * Generic, app-agnostic resizable two-pane split. Drag the divider with a
35
+ * pointer or nudge it with the arrow keys. Nothing domain-specific lives here.
36
+ */
37
+ export function SplitPane({
38
+ first,
39
+ second,
40
+ orientation = 'vertical',
41
+ initialSize = 50,
42
+ minSize = 20,
43
+ maxSize = 80,
44
+ step = 3,
45
+ storageKey,
46
+ onResize,
47
+ className,
48
+ 'aria-label': ariaLabel = 'Resize panes',
49
+ }: SplitPaneProps) {
50
+ const isVertical = orientation === 'vertical'
51
+ const containerRef = React.useRef<HTMLDivElement>(null)
52
+ const draggingRef = React.useRef(false)
53
+
54
+ const [size, setSizeState] = React.useState<number>(() => {
55
+ if (typeof window !== 'undefined' && storageKey) {
56
+ const saved = window.localStorage.getItem(storageKey)
57
+ if (saved != null) {
58
+ const n = Number(saved)
59
+ if (!Number.isNaN(n)) return clamp(n, minSize, maxSize)
60
+ }
61
+ }
62
+ return clamp(initialSize, minSize, maxSize)
63
+ })
64
+
65
+ const setSize = React.useCallback(
66
+ (next: number) => {
67
+ const clamped = clamp(next, minSize, maxSize)
68
+ setSizeState(clamped)
69
+ onResize?.(clamped)
70
+ if (typeof window !== 'undefined' && storageKey) {
71
+ window.localStorage.setItem(storageKey, String(clamped))
72
+ }
73
+ },
74
+ [minSize, maxSize, onResize, storageKey]
75
+ )
76
+
77
+ const onKeyDown = (e: React.KeyboardEvent) => {
78
+ const grow = isVertical ? 'ArrowRight' : 'ArrowDown'
79
+ const shrink = isVertical ? 'ArrowLeft' : 'ArrowUp'
80
+ if (e.key === grow) {
81
+ e.preventDefault()
82
+ setSize(size + step)
83
+ } else if (e.key === shrink) {
84
+ e.preventDefault()
85
+ setSize(size - step)
86
+ } else if (e.key === 'Home') {
87
+ e.preventDefault()
88
+ setSize(minSize)
89
+ } else if (e.key === 'End') {
90
+ e.preventDefault()
91
+ setSize(maxSize)
92
+ }
93
+ }
94
+
95
+ const onPointerMove = React.useCallback(
96
+ (e: PointerEvent) => {
97
+ if (!draggingRef.current || !containerRef.current) return
98
+ const rect = containerRef.current.getBoundingClientRect()
99
+ const pct = isVertical
100
+ ? ((e.clientX - rect.left) / rect.width) * 100
101
+ : ((e.clientY - rect.top) / rect.height) * 100
102
+ setSize(pct)
103
+ },
104
+ [isVertical, setSize]
105
+ )
106
+
107
+ const stopDragging = React.useCallback(() => {
108
+ draggingRef.current = false
109
+ document.body.style.cursor = ''
110
+ document.body.style.userSelect = ''
111
+ }, [])
112
+
113
+ React.useEffect(() => {
114
+ window.addEventListener('pointermove', onPointerMove)
115
+ window.addEventListener('pointerup', stopDragging)
116
+ return () => {
117
+ window.removeEventListener('pointermove', onPointerMove)
118
+ window.removeEventListener('pointerup', stopDragging)
119
+ }
120
+ }, [onPointerMove, stopDragging])
121
+
122
+ const startDragging = () => {
123
+ draggingRef.current = true
124
+ document.body.style.cursor = isVertical ? 'col-resize' : 'row-resize'
125
+ document.body.style.userSelect = 'none'
126
+ }
127
+
128
+ return (
129
+ <div
130
+ ref={containerRef}
131
+ className={cn(
132
+ 'flex h-full w-full overflow-hidden',
133
+ isVertical ? 'flex-row' : 'flex-col',
134
+ className
135
+ )}
136
+ data-orientation={orientation}
137
+ >
138
+ <div
139
+ className="min-h-0 min-w-0 overflow-hidden"
140
+ style={isVertical ? { width: `${size}%` } : { height: `${size}%` }}
141
+ data-pane="first"
142
+ >
143
+ {first}
144
+ </div>
145
+ <div
146
+ role="separator"
147
+ aria-label={ariaLabel}
148
+ aria-orientation={orientation}
149
+ aria-valuenow={Math.round(size)}
150
+ aria-valuemin={minSize}
151
+ aria-valuemax={maxSize}
152
+ tabIndex={0}
153
+ onKeyDown={onKeyDown}
154
+ onPointerDown={startDragging}
155
+ className={cn(
156
+ 'group relative shrink-0 bg-border transition-colors hover:bg-primary/40 focus-visible:bg-primary/60 focus:outline-none',
157
+ isVertical ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize'
158
+ )}
159
+ data-testid="split-pane-resizer"
160
+ >
161
+ <span
162
+ aria-hidden="true"
163
+ className={cn(
164
+ 'absolute bg-transparent',
165
+ isVertical ? '-inset-x-1.5 inset-y-0' : '-inset-y-1.5 inset-x-0'
166
+ )}
167
+ />
168
+ </div>
169
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden" data-pane="second">
170
+ {second}
171
+ </div>
172
+ </div>
173
+ )
174
+ }
@@ -0,0 +1,4 @@
1
+ export { SplitPane } from './SplitPane'
2
+ export type { SplitPaneProps } from './SplitPane'
3
+ export { DualPaneWorkspace } from './DualPaneWorkspace'
4
+ export type { DualPaneWorkspaceProps, WorkspaceLayout } from './DualPaneWorkspace'