@adea-ai/ui 0.64.0 → 0.65.1

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 (43) hide show
  1. package/README.md +50 -0
  2. package/dist/NOTICE +59 -1
  3. package/dist/components/composites/update-dialog/update-dialog.js +2 -2
  4. package/dist/components/composites/update-dialog/update-dialog.js.map +1 -1
  5. package/dist/components/layout/split-layout/drop.d.ts +13 -0
  6. package/dist/components/layout/split-layout/drop.d.ts.map +1 -0
  7. package/dist/components/layout/split-layout/drop.js +50 -0
  8. package/dist/components/layout/split-layout/drop.js.map +1 -0
  9. package/dist/components/layout/split-layout/geometry.d.ts +14 -0
  10. package/dist/components/layout/split-layout/geometry.d.ts.map +1 -0
  11. package/dist/components/layout/split-layout/geometry.js +44 -0
  12. package/dist/components/layout/split-layout/geometry.js.map +1 -0
  13. package/dist/components/layout/split-layout/index.d.ts +5 -0
  14. package/dist/components/layout/split-layout/index.d.ts.map +1 -0
  15. package/dist/components/layout/split-layout/index.js +4 -0
  16. package/dist/components/layout/split-layout/model.d.ts +51 -0
  17. package/dist/components/layout/split-layout/model.d.ts.map +1 -0
  18. package/dist/components/layout/split-layout/model.js +255 -0
  19. package/dist/components/layout/split-layout/model.js.map +1 -0
  20. package/dist/components/layout/split-layout/split-layout.d.ts +30 -0
  21. package/dist/components/layout/split-layout/split-layout.d.ts.map +1 -0
  22. package/dist/components/layout/split-layout/split-layout.js +312 -0
  23. package/dist/components/layout/split-layout/split-layout.js.map +1 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -1
  27. package/dist/r/registry.json +41 -0
  28. package/dist/r/split-layout.json +41 -0
  29. package/dist/r/src/components/composites/update-dialog/update-dialog.tsx +4 -4
  30. package/dist/r/src/components/layout/split-layout/drop.ts +29 -0
  31. package/dist/r/src/components/layout/split-layout/geometry.ts +40 -0
  32. package/dist/r/src/components/layout/split-layout/index.ts +4 -0
  33. package/dist/r/src/components/layout/split-layout/model.ts +336 -0
  34. package/dist/r/src/components/layout/split-layout/split-layout.tsx +346 -0
  35. package/package.json +10 -2
  36. package/registry.json +41 -0
  37. package/src/components/composites/update-dialog/update-dialog.tsx +4 -4
  38. package/src/components/layout/split-layout/drop.ts +29 -0
  39. package/src/components/layout/split-layout/geometry.ts +40 -0
  40. package/src/components/layout/split-layout/index.ts +4 -0
  41. package/src/components/layout/split-layout/model.ts +336 -0
  42. package/src/components/layout/split-layout/split-layout.tsx +346 -0
  43. package/src/index.ts +1 -0
@@ -0,0 +1,346 @@
1
+ import Resizable from '@corvu/resizable'
2
+ import {
3
+ For,
4
+ createMemo,
5
+ createComputed,
6
+ createUniqueId,
7
+ createSignal,
8
+ onCleanup,
9
+ on,
10
+ type Accessor,
11
+ type JSX,
12
+ } from 'solid-js'
13
+ import { X, GripVertical } from 'lucide-solid'
14
+ import { Button } from '../../ui/button'
15
+ import { cn } from '#lib/utils'
16
+ import { computeLayoutFrames, type LayoutRect } from './geometry'
17
+ import { paneDropIntent, type PaneDropIntent } from './drop'
18
+ export type { PaneDropIntent } from './drop'
19
+ const PANE_DRAG_TYPE = 'application/x-adea-pane-move'
20
+ import {
21
+ listLeaves,
22
+ MIN_SPLIT_RATIO,
23
+ MAX_SPLIT_RATIO,
24
+ type SplitLayoutBranch,
25
+ type SplitLayoutLeaf,
26
+ type SplitLayoutState,
27
+ } from './model'
28
+
29
+ export type SplitLayoutProps<L extends SplitLayoutLeaf> = {
30
+ state: SplitLayoutState<L>
31
+ label: string
32
+ /** Accessible name for a pane region and its close button. */
33
+ labelForLeaf: (leaf: L) => string
34
+ /** Called once per stable leaf owner; the accessor tracks opaque payload replacement. */
35
+ renderLeaf: (leaf: Accessor<L>) => JSX.Element
36
+ /** Inline visible header composition, separate from the region and close-button name. */
37
+ renderPaneLabel?: (leaf: Accessor<L>) => JSX.Element
38
+ /** Whether pane regions participate in sequential keyboard navigation; defaults to programmatic focus only. */
39
+ paneTabIndex?: 0 | -1
40
+ /** Accessible splitter name; defaults to the current orientation-specific label. */
41
+ labelForSeparator?: (branch: SplitLayoutBranch<L>) => string
42
+ onResize: (splitId: string, ratio: number) => void
43
+ onFocus?: (leafId: string) => void
44
+ /** Host performs the model transition and returns the surviving focus destination. */
45
+ onClose?: (leafId: string) => string | undefined
46
+ /** Pointer placement only; the host supplies keyboard commands and performs the model transition. */
47
+ onMove?: (leafId: string, targetId: string, intent: PaneDropIntent) => void
48
+ /** Stable header composition for host-owned keyboard actions/capability feedback. */
49
+ renderPaneActions?: (leaf: Accessor<L>) => JSX.Element
50
+ class?: string
51
+ }
52
+ function rectStyle(rect: LayoutRect): JSX.CSSProperties {
53
+ return {
54
+ left: `${rect.x * 100}%`,
55
+ top: `${rect.y * 100}%`,
56
+ width: `${rect.width * 100}%`,
57
+ height: `${rect.height * 100}%`,
58
+ }
59
+ }
60
+ /** Muxy frame geometry keeps leaf owners stable; Corvu owns constrained separator interactions. */
61
+ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L>) {
62
+ let root: HTMLDivElement | undefined
63
+ let pendingFrame: number | undefined
64
+ const refs = new Map<string, HTMLElement>()
65
+ const domIds = new Map<string, string>()
66
+ const prefix = createUniqueId()
67
+ let nextDomId = 0
68
+ let nextDragId = 0
69
+ const [drag, setDrag] = createSignal<{ id: string; token: string }>()
70
+ const [drop, setDrop] = createSignal<{ id: string; intent: PaneDropIntent }>()
71
+ const clearDrag = () => {
72
+ setDrag(undefined)
73
+ setDrop(undefined)
74
+ }
75
+ const domId = (id: string) => {
76
+ const existing = domIds.get(id)
77
+ if (existing) return existing
78
+ const created = `${prefix}-pane-${nextDomId++}`
79
+ domIds.set(id, created)
80
+ return created
81
+ }
82
+ const leaves = createMemo(() => listLeaves(props.state.center))
83
+ const leafMap = createMemo(() => new Map(leaves().map((leaf) => [leaf.id, leaf])))
84
+ const frames = createMemo(() => computeLayoutFrames(props.state.center))
85
+ const branches = createMemo(() =>
86
+ Array.from(frames().values()).flatMap((frame) =>
87
+ frame.node.kind === 'split' ? [frame.node] : []
88
+ )
89
+ )
90
+ createComputed(() => {
91
+ const current = drag()
92
+ if (current && (!props.onMove || !leafMap().has(current.id))) clearDrag()
93
+ const target = drop()
94
+ if (target && !leafMap().has(target.id)) setDrop(undefined)
95
+ })
96
+ const allowedTarget = (id: string, transfer: DataTransfer | null) => {
97
+ const current = drag()
98
+ return props.onMove &&
99
+ current &&
100
+ current.id !== id &&
101
+ leafMap().has(current.id) &&
102
+ leafMap().has(id) &&
103
+ transfer?.types.includes(PANE_DRAG_TYPE)
104
+ ? current
105
+ : undefined
106
+ }
107
+ const dropLabel = createMemo(() => {
108
+ const target = drop()
109
+ const leaf = target ? leafMap().get(target.id) : undefined
110
+ return target && leaf
111
+ ? `Drop to place pane ${target.intent.placement} ${props.labelForLeaf(leaf)}`
112
+ : ''
113
+ })
114
+ const branchMap = createMemo(() => new Map(branches().map((branch) => [branch.id, branch])))
115
+ const schedule = (action: () => void) => {
116
+ if (pendingFrame !== undefined) cancelAnimationFrame(pendingFrame)
117
+ pendingFrame = requestAnimationFrame(() => {
118
+ pendingFrame = undefined
119
+ action()
120
+ })
121
+ }
122
+ // Register before keyed children update: capture a focused editor before an
123
+ // ordered DOM move can blur it. Never override a newer focus outside this root.
124
+ createComputed(
125
+ on(
126
+ () => props.state.center,
127
+ () => {
128
+ const active = root?.ownerDocument.activeElement
129
+ if (!root || !(active instanceof HTMLElement) || !root.contains(active)) return
130
+ const field =
131
+ active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement
132
+ ? active
133
+ : undefined
134
+ const selection = field
135
+ ? {
136
+ start: field.selectionStart,
137
+ end: field.selectionEnd,
138
+ direction: field.selectionDirection,
139
+ }
140
+ : undefined
141
+ schedule(() => {
142
+ if (!active.isConnected || !root?.contains(active)) return
143
+ const current = active.ownerDocument.activeElement
144
+ if (current !== active && current !== active.ownerDocument.body) return
145
+ active.focus({ preventScroll: true })
146
+ if (field && selection?.start !== null && selection?.end !== null && selection)
147
+ field.setSelectionRange(
148
+ selection.start,
149
+ selection.end,
150
+ selection.direction ?? undefined
151
+ )
152
+ })
153
+ }
154
+ )
155
+ )
156
+ onCleanup(() => {
157
+ if (pendingFrame !== undefined) cancelAnimationFrame(pendingFrame)
158
+ clearDrag()
159
+ refs.clear()
160
+ domIds.clear()
161
+ })
162
+ const close = (id: string) => {
163
+ const next = props.onClose?.(id)
164
+ if (next) schedule(() => refs.get(next)?.focus({ preventScroll: true }))
165
+ }
166
+ return (
167
+ <div
168
+ ref={(el) => {
169
+ root = el
170
+ }}
171
+ role="group"
172
+ aria-label={props.label}
173
+ data-slot="split-layout"
174
+ class={cn('relative size-full min-h-0 min-w-0', props.class)}
175
+ >
176
+ <For each={leaves().map((leaf) => leaf.id)}>
177
+ {(id) => {
178
+ const initial = leafMap().get(id)!
179
+ const leaf = () => leafMap().get(id) ?? initial
180
+ const content = props.renderLeaf(leaf)
181
+ const paneLabel = props.renderPaneLabel?.(leaf)
182
+ const actions = props.renderPaneActions?.(leaf)
183
+ const intent = () => (drop()?.id === id ? drop()?.intent : undefined)
184
+ onCleanup(() => {
185
+ refs.delete(id)
186
+ domIds.delete(id)
187
+ })
188
+ return (
189
+ <section
190
+ ref={(el) => refs.set(id, el)}
191
+ id={domId(id)}
192
+ role="region"
193
+ aria-label={props.labelForLeaf(leaf())}
194
+ tabIndex={props.paneTabIndex ?? -1}
195
+ data-pane-id={id}
196
+ data-focused={props.state.focusedLeafId === id ? '' : undefined}
197
+ data-drop-direction={intent()?.direction}
198
+ data-drop-placement={intent()?.placement}
199
+ onDragOver={(event) => {
200
+ if (!allowedTarget(id, event.dataTransfer)) return
201
+ const next = paneDropIntent(
202
+ event.clientX,
203
+ event.clientY,
204
+ event.currentTarget.getBoundingClientRect()
205
+ )
206
+ if (!next) {
207
+ setDrop(undefined)
208
+ return
209
+ }
210
+ event.preventDefault()
211
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
212
+ setDrop({ id, intent: next })
213
+ }}
214
+ onDragLeave={(event) => {
215
+ if (
216
+ event.relatedTarget instanceof Node &&
217
+ event.currentTarget.contains(event.relatedTarget)
218
+ )
219
+ return
220
+ if (drop()?.id === id) setDrop(undefined)
221
+ }}
222
+ onDrop={(event) => {
223
+ const current = allowedTarget(id, event.dataTransfer)
224
+ const next = paneDropIntent(
225
+ event.clientX,
226
+ event.clientY,
227
+ event.currentTarget.getBoundingClientRect()
228
+ )
229
+ const accepted =
230
+ current && next && event.dataTransfer?.getData(PANE_DRAG_TYPE) === current.token
231
+ clearDrag()
232
+ if (!accepted) return
233
+ event.preventDefault()
234
+ props.onMove?.(current.id, id, next)
235
+ }}
236
+ style={rectStyle(frames().get(id)?.rect ?? { x: 0, y: 0, width: 0, height: 0 })}
237
+ class="absolute flex min-h-0 min-w-0 flex-col overflow-hidden border border-border data-[focused]:border-primary bg-background text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"
238
+ onFocusIn={() => props.onFocus?.(id)}
239
+ >
240
+ <header class="flex min-w-0 shrink-0 items-center gap-2 bg-surface px-2">
241
+ <span
242
+ data-pane-drag-handle=""
243
+ draggable={Boolean(props.onMove)}
244
+ class="flex min-w-0 flex-1 items-center gap-2 truncate text-sm"
245
+ title={props.onMove ? `Drag ${props.labelForLeaf(leaf())} to move` : undefined}
246
+ onDragStart={(event) => {
247
+ if (!props.onMove || !event.dataTransfer) {
248
+ event.preventDefault()
249
+ return
250
+ }
251
+ const token = `${prefix}-${++nextDragId}`
252
+ setDrag({ id, token })
253
+ event.dataTransfer.setData(PANE_DRAG_TYPE, token)
254
+ event.dataTransfer.effectAllowed = 'move'
255
+ }}
256
+ onDragEnd={clearDrag}
257
+ >
258
+ {props.onMove ? (
259
+ <GripVertical class="size-3 shrink-0" aria-hidden="true" />
260
+ ) : null}
261
+ <span class="min-w-0 flex-1 truncate">
262
+ {paneLabel ?? props.labelForLeaf(leaf())}
263
+ </span>
264
+ </span>
265
+ {actions}
266
+ {props.onClose ? (
267
+ <Button
268
+ variant="ghost"
269
+ size="icon-xs"
270
+ aria-label={`Close ${props.labelForLeaf(leaf())}`}
271
+ onClick={() => close(id)}
272
+ >
273
+ <X />
274
+ </Button>
275
+ ) : null}
276
+ </header>
277
+ <div class="min-h-0 min-w-0 flex-1 overflow-auto">{content}</div>
278
+ {intent() ? (
279
+ <div
280
+ aria-hidden="true"
281
+ class={cn('pointer-events-none absolute border-2 border-primary bg-primary/10', {
282
+ 'inset-y-0 left-0 w-1/2':
283
+ intent()?.direction === 'row' && intent()?.placement === 'before',
284
+ 'inset-y-0 right-0 w-1/2':
285
+ intent()?.direction === 'row' && intent()?.placement === 'after',
286
+ 'inset-x-0 top-0 h-1/2':
287
+ intent()?.direction === 'column' && intent()?.placement === 'before',
288
+ 'inset-x-0 bottom-0 h-1/2':
289
+ intent()?.direction === 'column' && intent()?.placement === 'after',
290
+ })}
291
+ />
292
+ ) : null}
293
+ </section>
294
+ )
295
+ }}
296
+ </For>
297
+ <span class="sr-only" role="status">
298
+ {dropLabel()}
299
+ </span>
300
+ <For each={branches().map((branch) => branch.id)}>
301
+ {(id) => {
302
+ const initial = branchMap().get(id)!
303
+ const branch: Accessor<SplitLayoutBranch<L>> = () => branchMap().get(id) ?? initial
304
+ return (
305
+ <Resizable
306
+ orientation={branch().direction === 'row' ? 'horizontal' : 'vertical'}
307
+ sizes={[branch().ratio, 1 - branch().ratio]}
308
+ keyboardDelta={0.05}
309
+ onSizesChange={(sizes) => {
310
+ if (sizes[0] !== undefined && Math.abs(sizes[0] - branch().ratio) > 0.000001)
311
+ props.onResize(id, sizes[0])
312
+ }}
313
+ style={rectStyle(frames().get(id)?.rect ?? { x: 0, y: 0, width: 0, height: 0 })}
314
+ class="pointer-events-none absolute flex min-h-0 min-w-0 data-[orientation=vertical]:flex-col"
315
+ >
316
+ <Resizable.Panel
317
+ minSize={MIN_SPLIT_RATIO}
318
+ maxSize={MAX_SPLIT_RATIO}
319
+ aria-hidden="true"
320
+ />
321
+ <Resizable.Handle
322
+ aria-label={
323
+ props.labelForSeparator?.(branch()) ??
324
+ (branch().direction === 'row' ? 'Resize pane columns' : 'Resize pane rows')
325
+ }
326
+ aria-orientation={branch().direction === 'row' ? 'vertical' : 'horizontal'}
327
+ aria-controls={listLeaves(branch())
328
+ .map((leaf) => domId(leaf.id))
329
+ .join(' ')}
330
+ aria-valuemin={10}
331
+ aria-valuemax={90}
332
+ aria-valuenow={Math.round(branch().ratio * 100)}
333
+ class="pointer-events-auto relative w-px shrink-0 bg-border after:absolute after:inset-y-0 after:-inset-x-1 after:w-3 focus-visible:bg-primary focus-visible:outline-none hover:bg-primary data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:inset-x-0 data-[orientation=vertical]:after:-inset-y-1 data-[orientation=vertical]:after:h-3 data-[orientation=vertical]:after:w-full"
334
+ />
335
+ <Resizable.Panel
336
+ minSize={MIN_SPLIT_RATIO}
337
+ maxSize={MAX_SPLIT_RATIO}
338
+ aria-hidden="true"
339
+ />
340
+ </Resizable>
341
+ )
342
+ }}
343
+ </For>
344
+ </div>
345
+ )
346
+ }
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ export * from './components/ui/tooltip'
77
77
  export * from './components/motion'
78
78
 
79
79
  /* --- Layout -------------------------------------------------------------- */
80
+ export * from './components/layout/split-layout'
80
81
  export * from './components/layout/app-shell'
81
82
  export * from './components/layout/page'
82
83
  export * from './components/layout/panel'