@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.
- package/README.md +50 -0
- package/dist/NOTICE +59 -1
- package/dist/components/composites/update-dialog/update-dialog.js +2 -2
- package/dist/components/composites/update-dialog/update-dialog.js.map +1 -1
- package/dist/components/layout/split-layout/drop.d.ts +13 -0
- package/dist/components/layout/split-layout/drop.d.ts.map +1 -0
- package/dist/components/layout/split-layout/drop.js +50 -0
- package/dist/components/layout/split-layout/drop.js.map +1 -0
- package/dist/components/layout/split-layout/geometry.d.ts +14 -0
- package/dist/components/layout/split-layout/geometry.d.ts.map +1 -0
- package/dist/components/layout/split-layout/geometry.js +44 -0
- package/dist/components/layout/split-layout/geometry.js.map +1 -0
- package/dist/components/layout/split-layout/index.d.ts +5 -0
- package/dist/components/layout/split-layout/index.d.ts.map +1 -0
- package/dist/components/layout/split-layout/index.js +4 -0
- package/dist/components/layout/split-layout/model.d.ts +51 -0
- package/dist/components/layout/split-layout/model.d.ts.map +1 -0
- package/dist/components/layout/split-layout/model.js +255 -0
- package/dist/components/layout/split-layout/model.js.map +1 -0
- package/dist/components/layout/split-layout/split-layout.d.ts +30 -0
- package/dist/components/layout/split-layout/split-layout.d.ts.map +1 -0
- package/dist/components/layout/split-layout/split-layout.js +312 -0
- package/dist/components/layout/split-layout/split-layout.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/r/registry.json +41 -0
- package/dist/r/split-layout.json +41 -0
- package/dist/r/src/components/composites/update-dialog/update-dialog.tsx +4 -4
- package/dist/r/src/components/layout/split-layout/drop.ts +29 -0
- package/dist/r/src/components/layout/split-layout/geometry.ts +40 -0
- package/dist/r/src/components/layout/split-layout/index.ts +4 -0
- package/dist/r/src/components/layout/split-layout/model.ts +336 -0
- package/dist/r/src/components/layout/split-layout/split-layout.tsx +346 -0
- package/package.json +10 -2
- package/registry.json +41 -0
- package/src/components/composites/update-dialog/update-dialog.tsx +4 -4
- package/src/components/layout/split-layout/drop.ts +29 -0
- package/src/components/layout/split-layout/geometry.ts +40 -0
- package/src/components/layout/split-layout/index.ts +4 -0
- package/src/components/layout/split-layout/model.ts +336 -0
- package/src/components/layout/split-layout/split-layout.tsx +346 -0
- 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'
|