svelte-fluentui 1.3.2 → 1.4.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 (35) hide show
  1. package/README.md +8 -9
  2. package/dist/components/Accordion.svelte +1 -1
  3. package/dist/components/AccordionItem.svelte +7 -3
  4. package/dist/components/Anchor.svelte +12 -12
  5. package/dist/components/BreadcrumbItem.svelte +1 -1
  6. package/dist/components/Button.svelte +16 -16
  7. package/dist/components/Checkbox.svelte +10 -10
  8. package/dist/components/Combobox.svelte +13 -13
  9. package/dist/components/DataGrid.svelte +4 -4
  10. package/dist/components/DataGridCell.svelte +8 -8
  11. package/dist/components/DataGridRow.svelte +3 -3
  12. package/dist/components/Dialog.svelte +5 -5
  13. package/dist/components/Listbox.svelte +3 -3
  14. package/dist/components/MenuButton.svelte +3 -3
  15. package/dist/components/NumberField.svelte +15 -15
  16. package/dist/components/Option.svelte +6 -6
  17. package/dist/components/Paginator.svelte +4 -4
  18. package/dist/components/QuickGrid.svelte +1 -1
  19. package/dist/components/Select.svelte +13 -0
  20. package/dist/components/Slider.svelte +12 -12
  21. package/dist/components/Switch.svelte +8 -8
  22. package/dist/components/TabPanel.svelte +1 -1
  23. package/dist/components/TextField.svelte +14 -14
  24. package/dist/components/Textarea.svelte +16 -16
  25. package/dist/components/Toolbar.svelte +3 -3
  26. package/dist/components/layout/MultiSplitter.svelte +1152 -157
  27. package/dist/components/layout/MultiSplitter.svelte.d.ts +19 -9
  28. package/dist/components/layout/MultiSplitterPane.svelte +78 -99
  29. package/dist/components/layout/MultiSplitterPane.svelte.d.ts +7 -4
  30. package/dist/components/layout/index.d.ts +1 -0
  31. package/dist/types/multi-splitter.d.ts +35 -0
  32. package/dist/types/multi-splitter.js +9 -0
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/package.json +1 -1
@@ -1,241 +1,1236 @@
1
+ <!--
2
+ * MultiSplitter
3
+ *
4
+ * Resizable container with N panes (N ≥ 2). Drag a gutter to resize,
5
+ * arrow keys nudge size when a gutter is focused, double-click toggles
6
+ * minimize when an adjacent pane opts in via `minimize` prop.
7
+ *
8
+ * Ported from pureadmin.io's splitter (packages/core/src/js/splitter.js)
9
+ * into Svelte 5. Behaviour notes inline; the original is the canonical
10
+ * reference for the rebalance / accordion / snap-to-rail subtleties.
11
+ *
12
+ * Drag model is REBALANCE (not boundary-coupled): each gutter resizes its
13
+ * primary neighbour (edge-closer side) and the matching opposite delta is
14
+ * distributed across the contiguous block of non-rail panes on the
15
+ * secondary side. Rail panes stay pinned at railSize and don't participate.
16
+ -->
17
+
18
+ <script lang="ts" module>
19
+ export type {MultiSplitterContext, PaneHandle, PaneRegistration} from "../../types/multi-splitter.js"
20
+
21
+ export type MultiSplitterResizeDetail = {index: number; pane: HTMLElement; size: number}
22
+ export type MultiSplitterToggleDetail = {index: number; pane: HTMLElement}
23
+ </script>
24
+
1
25
  <script lang="ts">
26
+ import {setContext, onMount} from "svelte"
2
27
  import type {SlotType} from "../../types/index.js"
3
- import {setContext} from "svelte"
28
+ import type {MultiSplitterContext, PaneHandle, PaneRegistration} from "../../types/multi-splitter.js"
4
29
 
5
30
  type Orientation = "horizontal" | "vertical"
6
- type PaneStatus = "normal" | "collapsed" | "expanded"
7
-
8
- export type MultiSplitterEventArgs = {
9
- index: number
10
- pane: HTMLElement
11
- }
12
-
13
- export type MultiSplitterResizeEventArgs = {
14
- index: number
15
- pane: HTMLElement
16
- size: number
17
- }
18
31
 
19
32
  type Props = {
20
33
  children?: SlotType
21
34
  orientation?: Orientation
22
- barSize?: string
35
+ /** localStorage persistence key — saves under "fluent-multi-splitter:<id>". */
36
+ id?: string
37
+ /** Keyboard step in px when a gutter is focused. */
38
+ step?: number
39
+ /** Rail width in px when a pane is minimized. */
40
+ railSize?: number
41
+ /** Drag-to-rail snap threshold as ratio of the drag-start size (0–1). */
42
+ minimizeThreshold?: number
23
43
  width?: string
24
44
  height?: string
25
- onCollapse?: (args: MultiSplitterEventArgs) => void
26
- onExpand?: (args: MultiSplitterEventArgs) => void
27
- onResize?: (args: MultiSplitterResizeEventArgs) => void
28
45
  class?: string
29
46
  style?: string
47
+ /** Logs drag / minimize transitions to console (development aid). */
48
+ debug?: boolean
49
+ onresize?: (detail: MultiSplitterResizeDetail) => void
50
+ oncollapse?: (detail: MultiSplitterToggleDetail) => void
51
+ onexpand?: (detail: MultiSplitterToggleDetail) => void
30
52
  }
31
53
 
32
54
  let {
33
55
  children = undefined,
34
56
  orientation = "horizontal",
35
- barSize = "6px",
57
+ id: persistId = undefined,
58
+ step = 10,
59
+ railSize = 40,
60
+ minimizeThreshold = 0.40,
36
61
  width = undefined,
37
62
  height = undefined,
38
- onCollapse = undefined,
39
- onExpand = undefined,
40
- onResize = undefined,
41
63
  class: className = "",
42
- style = ""
64
+ style = "",
65
+ debug = false,
66
+ onresize = undefined,
67
+ oncollapse = undefined,
68
+ onexpand = undefined
43
69
  }: Props = $props()
44
70
 
45
- let element: HTMLDivElement | undefined = $state()
46
- let panes: Array<{
47
- element: HTMLElement
48
- size?: string
49
- minSize?: string
50
- maxSize?: string
51
- resizable: boolean
52
- collapsible: boolean
53
- status: PaneStatus
54
- }> = $state([])
55
-
56
- let isResizing = $state(false)
57
- let resizingIndex = $state<number>(-1)
58
- let startPos = $state(0)
59
- let startSize = $state(0)
60
-
61
- // Set context for child panes
62
- setContext("multisplitter", {
63
- get orientation() { return orientation },
64
- registerPane: (pane: any) => {
65
- panes = [...panes, pane]
66
- return panes.length - 1
67
- },
68
- unregisterPane: (index: number) => {
69
- panes = panes.filter((_, i) => i !== index)
70
- },
71
- resizeExec: async (e: MouseEvent, index: number) => {
72
- e.preventDefault()
73
- e.stopPropagation()
71
+ const STORAGE_PREFIX = "fluent-multi-splitter:"
74
72
 
75
- if (!panes[index]?.resizable) return
73
+ const isVertical = $derived(orientation === "vertical")
74
+ const clientAxis = $derived<"clientHeight" | "clientWidth">(isVertical ? "clientHeight" : "clientWidth")
75
+ const clientCoord = $derived<"clientX" | "clientY">(isVertical ? "clientY" : "clientX")
76
76
 
77
- isResizing = true
78
- resizingIndex = index
79
- startPos = orientation === "horizontal" ? e.clientX : e.clientY
80
- const paneElement = panes[index].element
81
- startSize =
82
- orientation === "horizontal" ? paneElement.offsetWidth : paneElement.offsetHeight
83
- },
84
- collapseExec: async (e: MouseEvent, index: number) => {
85
- e.preventDefault()
86
- e.stopPropagation()
77
+ let rootEl: HTMLDivElement | undefined = $state()
78
+ let panes: PaneHandle[] = $state([])
87
79
 
88
- if (!panes[index]?.collapsible) return
80
+ // Class form so `state` can carry a `$state` field — Svelte 5 disallows
81
+ // `$state(...)` inside object literals, but allows it as a class field.
82
+ class PaneHandleImpl implements PaneHandle {
83
+ id: number
84
+ state = $state({isMin: false})
85
+ constructor(id: number) { this.id = id }
86
+ }
89
87
 
90
- const pane = panes[index]
91
- pane.status = "collapsed"
92
- panes = [...panes]
88
+ // Per-handle non-reactive working state. Hot drag loop reads from here.
89
+ type PaneInternal = {
90
+ el: HTMLElement
91
+ getGutterEl: () => HTMLElement | undefined
92
+ canMin: boolean
93
+ rawSize?: string
94
+ rawMin?: string
95
+ rawMax?: string
96
+ size: number
97
+ min: number
98
+ max: number
99
+ lastNonZero: number
100
+ }
101
+ const internals = new Map<PaneHandle, PaneInternal>()
102
+ let accordionActive = false
103
+ let nextHandleId = 0
93
104
 
94
- onCollapse?.({
95
- index,
96
- pane: pane.element
97
- })
98
- },
99
- expandExec: async (e: MouseEvent, index: number) => {
100
- e.preventDefault()
101
- e.stopPropagation()
105
+ function log(...args: unknown[]) {
106
+ if (!debug) return
107
+ console.log("[multi-splitter:" + (persistId || "anon") + "]", ...args)
108
+ }
102
109
 
103
- const pane = panes[index]
104
- if (pane.status !== "collapsed") return
110
+ function parseSize(raw: string | undefined, totalPx: number): number | null {
111
+ if (raw == null || raw === "") return null
112
+ const s = String(raw).trim()
113
+ if (s.endsWith("%")) {
114
+ const pct = parseFloat(s)
115
+ return isNaN(pct) ? null : (pct / 100) * totalPx
116
+ }
117
+ if (s.endsWith("px")) {
118
+ const px = parseFloat(s)
119
+ return isNaN(px) ? null : px
120
+ }
121
+ const n = parseFloat(s)
122
+ if (!isNaN(n) && /^-?\d*\.?\d+$/.test(s)) return n
123
+ return null
124
+ }
105
125
 
106
- pane.status = "normal"
107
- panes = [...panes]
126
+ function readStorage(): {sizes?: number[]; lasts?: number[]; minimized?: boolean[]} | null {
127
+ if (!persistId) return null
128
+ try {
129
+ const raw = localStorage.getItem(STORAGE_PREFIX + persistId)
130
+ if (!raw) return null
131
+ const parsed = JSON.parse(raw)
132
+ if (typeof parsed !== "object" || parsed === null || parsed.v !== 2) return null
133
+ return parsed
134
+ } catch { return null }
135
+ }
108
136
 
109
- onExpand?.({
110
- index,
111
- pane: pane.element
112
- })
137
+ function writeStorage() {
138
+ if (!persistId) return
139
+ const sizes = panes.map(h => internals.get(h)!.size)
140
+ const lasts = panes.map(h => internals.get(h)!.lastNonZero)
141
+ const minimized = panes.map(h => h.state.isMin)
142
+ try { localStorage.setItem(STORAGE_PREFIX + persistId, JSON.stringify({v: 2, sizes, lasts, minimized})) }
143
+ catch { /* quota/privacy mode */ }
144
+ }
145
+
146
+ function gapPx(): number {
147
+ if (!rootEl) return 0
148
+ const cs = getComputedStyle(rootEl)
149
+ const raw = isVertical ? cs.rowGap : cs.columnGap
150
+ const px = parseFloat(raw)
151
+ return isNaN(px) ? 0 : px
152
+ }
153
+
154
+ function paddingPx(): number {
155
+ if (!rootEl) return 0
156
+ const cs = getComputedStyle(rootEl)
157
+ const start = isVertical ? cs.paddingTop : cs.paddingLeft
158
+ const end = isVertical ? cs.paddingBottom : cs.paddingRight
159
+ return (parseFloat(start) || 0) + (parseFloat(end) || 0)
160
+ }
161
+
162
+ function gutterTotal(): number {
163
+ // Sum across all rendered gutters (last pane has none). Reads measured
164
+ // size so a per-instance --gutter-size override is honoured.
165
+ let t = 0
166
+ for (let i = 0; i < panes.length - 1; i++) {
167
+ const g = internals.get(panes[i])!.getGutterEl()
168
+ if (g) t += g[clientAxis]
113
169
  }
114
- })
170
+ return t
171
+ }
115
172
 
116
- function handleMouseMove(e: MouseEvent) {
117
- if (!isResizing || resizingIndex < 0) return
173
+ function totalAvailable(): number {
174
+ if (!rootEl) return 0
175
+ const N = panes.length
176
+ // Flex `gap` lands between every adjacent child. Panes + gutters
177
+ // alternate, so 2(N-1) boundaries.
178
+ const gapCount = 2 * (N - 1)
179
+ return rootEl[clientAxis] - paddingPx() - gutterTotal() - (gapCount * gapPx())
180
+ }
118
181
 
119
- const currentPos = orientation === "horizontal" ? e.clientX : e.clientY
120
- const delta = currentPos - startPos
121
- const newSize = startSize + delta
182
+ function resolveConstraints(): number {
183
+ if (!rootEl) return 0
184
+ const rootSize = rootEl[clientAxis]
185
+ const total = totalAvailable()
186
+ for (const h of panes) {
187
+ const p = internals.get(h)!
188
+ let mn = parseSize(p.rawMin, rootSize)
189
+ let mx = parseSize(p.rawMax, rootSize)
190
+ if (mn == null) mn = 0
191
+ if (mx == null) mx = total
192
+ if (mx > total) mx = total
193
+ if (mn > mx) mn = mx
194
+ p.min = mn
195
+ p.max = mx
196
+ }
197
+ return total
198
+ }
122
199
 
123
- const pane = panes[resizingIndex]
124
- const minSize = pane.minSize ? parseFloat(pane.minSize) : 0
125
- const maxSize = pane.maxSize ? parseFloat(pane.maxSize) : Infinity
200
+ // Clamp each pane to [min, max] and redistribute shortfall/excess to
201
+ // unclamped panes proportionally. `pinned` excludes panes from the flex
202
+ // pool (used to keep rail panes locked or constrain to absorber sets).
203
+ function clampToConstraints(total: number, pinned: boolean[]) {
204
+ const N = panes.length
205
+ const arr = panes.map(h => internals.get(h)!)
206
+ for (let pass = 0; pass < N + 1; pass++) {
207
+ let sum = 0
208
+ for (const p of arr) sum += p.size
209
+ const diff = total - sum
210
+ if (Math.abs(diff) < 0.5) break
211
+ const flexable: number[] = []
212
+ let flexableWeight = 0
213
+ for (let j = 0; j < N; j++) {
214
+ if (pinned[j]) continue
215
+ const p = arr[j]
216
+ const headroom = diff > 0 ? (p.max - p.size) : (p.size - p.min)
217
+ if (headroom > 0.5) {
218
+ flexable.push(j)
219
+ flexableWeight += p.size > 0 ? p.size : 1
220
+ }
221
+ }
222
+ if (flexable.length === 0) break
223
+ for (const idx of flexable) {
224
+ const p = arr[idx]
225
+ const share = diff * ((p.size > 0 ? p.size : 1) / flexableWeight)
226
+ let next = p.size + share
227
+ if (next < p.min) next = p.min
228
+ if (next > p.max) next = p.max
229
+ p.size = next
230
+ }
231
+ }
232
+ }
126
233
 
127
- if (newSize >= minSize && newSize <= maxSize) {
128
- if (orientation === "horizontal") {
129
- pane.element.style.width = `${newSize}px`
130
- } else {
131
- pane.element.style.height = `${newSize}px`
234
+ // Programmatic-reflow window: panes shift under the resting cursor when
235
+ // applySizes runs outside a drag — neutralise hover/selection flashes for
236
+ // ~250 ms via the --reflowing class. Shared timer; later calls extend
237
+ // the window rather than letting an earlier timer clear it too soon.
238
+ let reflowTimer: ReturnType<typeof setTimeout> | null = null
239
+ const REFLOW_MS = 250
240
+ function markReflow() {
241
+ if (!rootEl) return
242
+ if (rootEl.classList.contains("fluent-multi-splitter--dragging")) return
243
+ rootEl.classList.add("fluent-multi-splitter--reflowing")
244
+ if (reflowTimer != null) clearTimeout(reflowTimer)
245
+ reflowTimer = setTimeout(() => {
246
+ rootEl?.classList.remove("fluent-multi-splitter--reflowing")
247
+ reflowTimer = null
248
+ }, REFLOW_MS)
249
+ }
250
+
251
+ function applySizes(opts: {persist?: boolean} = {}) {
252
+ const N = panes.length
253
+ for (let i = 0; i < N; i++) {
254
+ const h = panes[i]
255
+ const p = internals.get(h)!
256
+ p.el.style.flexBasis = p.size + "px"
257
+ // lastNonZero captures the last "expanded" size for restore. Only
258
+ // trust values at or above the pane's min (so a drag-from-rail
259
+ // that couldn't move the boundary doesn't overwrite the original
260
+ // expanded width with the rail-floor value).
261
+ if (p.size >= p.min && !h.state.isMin) {
262
+ p.lastNonZero = p.size
263
+ }
264
+ // Fire onresize when size changed materially. Pre-applySizes the
265
+ // caller already wrote p.size; we don't track previous-frame
266
+ // size, so fire unconditionally and let consumers debounce.
267
+ onresize?.({index: i, pane: p.el, size: p.size})
268
+ }
269
+
270
+ // Update each gutter's ARIA. Uses left-pane size for valuenow.
271
+ for (let g = 0; g < N - 1; g++) {
272
+ const p = internals.get(panes[g])!
273
+ const gut = p.getGutterEl()
274
+ if (!gut) continue
275
+ gut.setAttribute("aria-valuenow", String(Math.round(p.size)))
276
+ gut.setAttribute("aria-valuemin", String(Math.round(p.min)))
277
+ gut.setAttribute("aria-valuemax", String(Math.round(p.max)))
278
+ }
279
+
280
+ if (opts.persist !== false) writeStorage()
281
+ markReflow()
282
+ }
283
+
284
+ function nonMinCount(): number {
285
+ let c = 0
286
+ for (const h of panes) if (!h.state.isMin) c++
287
+ return c
288
+ }
289
+
290
+ // Absorbers for a drag on gutter `g` (between panes g and g+1) with the
291
+ // given primary index. Two modes:
292
+ // CLASSIC: immediate secondary is non-rail → only that one absorbs.
293
+ // TUNNEL: immediate secondary is rail → skip rails, collect the
294
+ // contiguous non-rail block beyond. Lets slack punch through a rail
295
+ // wall, which is the only way drag-from-rail can grow the primary
296
+ // when its immediate neighbour is also rail.
297
+ function computeAbsorbers(g: number, primary: number): number[] {
298
+ const N = panes.length
299
+ const absorbers: number[] = []
300
+ let sawRail = false
301
+ if (primary === g) {
302
+ // Secondary side = right of gutter; walk from g+1 forward.
303
+ for (let i = g + 1; i < N; i++) {
304
+ if (panes[i].state.isMin) {
305
+ if (absorbers.length > 0) break
306
+ sawRail = true
307
+ continue
308
+ }
309
+ absorbers.push(i)
310
+ if (!sawRail) break
311
+ }
312
+ } else {
313
+ // Secondary side = left of gutter; walk from g backward.
314
+ for (let j = g; j >= 0; j--) {
315
+ if (panes[j].state.isMin) {
316
+ if (absorbers.length > 0) break
317
+ sawRail = true
318
+ continue
319
+ }
320
+ absorbers.push(j)
321
+ if (!sawRail) break
322
+ }
323
+ }
324
+ return absorbers
325
+ }
326
+
327
+ function pinnedForAbsorbers(absorbers: number[]): boolean[] {
328
+ const pinned = new Array(panes.length).fill(true) as boolean[]
329
+ for (const i of absorbers) pinned[i] = false
330
+ return pinned
331
+ }
332
+
333
+ // Pick the primary neighbour for gutter `g`: side closer to its
334
+ // container edge. LTR/RTL tiebreaker on ties.
335
+ function primaryNeighbour(g: number): number {
336
+ const N = panes.length
337
+ const leftDist = g
338
+ const rightDist = N - 2 - g
339
+ if (leftDist < rightDist) return g
340
+ if (rightDist < leftDist) return g + 1
341
+ return rootEl && getComputedStyle(rootEl).direction === "rtl" ? (g + 1) : g
342
+ }
343
+
344
+ // ---------- Drag state (per active drag) ----------
345
+ let activePointerId: number | null = null
346
+ let dragGutterIdx = -1
347
+ let dragStartCoord = 0
348
+ let dragStartPrimary = 0
349
+ let primaryIdx = -1
350
+ let primarySign = 1
351
+ let primaryStartedMin = false
352
+ let primaryInEscape = false
353
+ let primaryCanSnap = false
354
+ let primaryMaxReached = 0
355
+ let everMoved = false
356
+ const TAP_PX = 2
357
+ let pendingMove: number | null = null
358
+ let rafScheduled = false
359
+
360
+ function snapThreshold(anchor: number): number {
361
+ return Math.max(railSize * 1.5, railSize + (anchor - railSize) * minimizeThreshold)
362
+ }
363
+
364
+ function processMove() {
365
+ rafScheduled = false
366
+ if (pendingMove == null) return
367
+ const coord = pendingMove
368
+ pendingMove = null
369
+ const primary = internals.get(panes[primaryIdx])!
370
+ const primaryHandle = panes[primaryIdx]
371
+ const delta = coord - dragStartCoord
372
+ let newPrimary = dragStartPrimary + (primarySign * delta)
373
+
374
+ // SNAP-INTO-RAIL: only after primary committed to expanded (snap gate).
375
+ if (primary.canMin && primaryCanSnap) {
376
+ if (newPrimary < snapThreshold(primaryMaxReached) && nonMinCount() > 1) {
377
+ log("SNAP-INTO-RAIL primary=" + primaryIdx)
378
+ const absorbers = computeAbsorbers(dragGutterIdx, primaryIdx)
379
+ primaryHandle.state.isMin = true
380
+ primary.size = railSize
381
+ clampToConstraints(totalAvailable(), pinnedForAbsorbers(absorbers))
382
+ applySizes({persist: false})
383
+ oncollapse?.({index: primaryIdx, pane: primary.el})
384
+ return
132
385
  }
386
+ }
387
+
388
+ // DRAG-OUT-OF-RAIL: snapped mid-drag, user dragged back past threshold.
389
+ // Gated by !primaryStartedMin: if primary was railed BEFORE the drag began,
390
+ // the user must dblclick (or click the rail body) to expand it — drag won't.
391
+ if (!primaryStartedMin && primaryHandle.state.isMin && newPrimary >= snapThreshold(primaryMaxReached)) {
392
+ log("DRAG-OUT-OF-RAIL primary=" + primaryIdx)
393
+ primaryHandle.state.isMin = false
394
+ primaryInEscape = true
395
+ onexpand?.({index: primaryIdx, pane: primary.el})
396
+ }
397
+
398
+ // While still railed, this frame is a no-op — skip floor/max/rebalance
399
+ // so the cursor in the snap zone doesn't re-clamp primary up to min.
400
+ if (primaryHandle.state.isMin) return
401
+
402
+ const floor = ((primaryStartedMin && !primaryCanSnap) || primaryInEscape) ? railSize : primary.min
403
+ if (newPrimary < floor) newPrimary = floor
404
+ if (newPrimary > primary.max) newPrimary = primary.max
405
+
406
+ if (primaryInEscape && newPrimary >= primary.min) primaryInEscape = false
407
+ if (!primaryCanSnap && newPrimary >= primary.min) primaryCanSnap = true
408
+ if (newPrimary > primaryMaxReached) primaryMaxReached = newPrimary
409
+
410
+ primary.size = newPrimary
411
+ const total = totalAvailable()
412
+ const absorbers = computeAbsorbers(dragGutterIdx, primaryIdx)
413
+ clampToConstraints(total, pinnedForAbsorbers(absorbers))
133
414
 
134
- onResize?.({
135
- index: resizingIndex,
136
- pane: pane.element,
137
- size: newSize
415
+ // Post-check: if absorbers couldn't absorb fully, pull primary back
416
+ // by the overshoot so the layout fits.
417
+ let sum = 0
418
+ for (const h of panes) sum += internals.get(h)!.size
419
+ if (Math.abs(sum - total) > 0.5) {
420
+ primary.size -= (sum - total)
421
+ if (primary.size < floor) primary.size = floor
422
+ if (primary.size > primary.max) primary.size = primary.max
423
+ }
424
+ applySizes({persist: false})
425
+ }
426
+
427
+ function onWindowPointerMove(e: PointerEvent) {
428
+ if (e.pointerId !== activePointerId) return
429
+ const delta = e[clientCoord] - dragStartCoord
430
+ if (!everMoved && Math.abs(delta) >= TAP_PX) {
431
+ everMoved = true
432
+ }
433
+ // Asymmetric drag against an already-railed primary:
434
+ // inward (would shrink primary) → no-op, pane stays railed.
435
+ // outward (would grow primary) → release rail, drag grows the pane.
436
+ // `primarySign * delta > 0` is the outward direction (matches the
437
+ // formula in processMove: newPrimary = dragStartPrimary + primarySign*delta).
438
+ if (primaryStartedMin && panes[primaryIdx]?.state.isMin) {
439
+ if (primarySign * delta <= 0) return
440
+ if (Math.abs(delta) < TAP_PX) return
441
+ const handle = panes[primaryIdx]
442
+ handle.state.isMin = false
443
+ const primary = internals.get(handle)!
444
+ onexpand?.({index: primaryIdx, pane: primary.el})
445
+ }
446
+ pendingMove = e[clientCoord]
447
+ if (rafScheduled) return
448
+ rafScheduled = true
449
+ requestAnimationFrame(processMove)
450
+ }
451
+
452
+ function onWindowPointerUp(e: PointerEvent) {
453
+ if (e.pointerId !== activePointerId) return
454
+ // Flush pending rAF so a last-millisecond move doesn't fire after we
455
+ // make end-of-drag decisions.
456
+ if (rafScheduled && pendingMove != null) processMove()
457
+ rafScheduled = false
458
+ pendingMove = null
459
+
460
+ const primary = internals.get(panes[primaryIdx])
461
+ const primaryHandle = panes[primaryIdx]
462
+ const gut = internals.get(panes[dragGutterIdx])?.getGutterEl()
463
+ try { gut?.releasePointerCapture(e.pointerId) } catch { /* */ }
464
+ activePointerId = null
465
+ rootEl?.classList.remove("fluent-multi-splitter--dragging")
466
+ gut?.classList.remove("fluent-multi-splitter-gutter--active")
467
+ window.removeEventListener("pointermove", onWindowPointerMove)
468
+ window.removeEventListener("pointerup", onWindowPointerUp)
469
+ window.removeEventListener("pointercancel", onWindowPointerUp)
470
+
471
+ // (Tap-restore on the gutter is intentionally NOT implemented — a press
472
+ // without drag does nothing on the gutter itself. To expand a railed
473
+ // pane: dblclick the gutter, drag past the jitter threshold, or click
474
+ // the rail pane body (handlePaneClick in MultiSplitterPane).
475
+ // pa-splitter's TAP-RESTORE was here but the UX was surprising —
476
+ // users expected drag-to-be-explicit.)
477
+
478
+ // RAIL-STAYED: started rail, never grew above rail → restore isMin.
479
+ let anyChange = false
480
+ if (primary && primaryHandle && primaryStartedMin && Math.abs(primary.size - railSize) < 1 && !primaryHandle.state.isMin) {
481
+ log("RAIL-STAYED primary=" + primaryIdx)
482
+ primaryHandle.state.isMin = true
483
+ anyChange = true
484
+ }
485
+
486
+ // CLAMP-TO-MIN: any non-rail pane below its min gets bumped; remainder
487
+ // redistributed across all non-rail panes.
488
+ let anyBelowMin = false
489
+ for (const h of panes) {
490
+ const p = internals.get(h)!
491
+ if (!h.state.isMin && p.size < p.min - 0.5) { anyBelowMin = true; break }
492
+ }
493
+ if (anyBelowMin) {
494
+ log("CLAMP-TO-MIN")
495
+ for (const h of panes) {
496
+ const p = internals.get(h)!
497
+ if (!h.state.isMin && p.size < p.min) p.size = p.min
498
+ }
499
+ const pinned = panes.map(h => h.state.isMin)
500
+ clampToConstraints(totalAvailable(), pinned)
501
+ applySizes({persist: false})
502
+ } else if (anyChange) {
503
+ applySizes({persist: false})
504
+ }
505
+ cleanupDrag()
506
+ writeStorage()
507
+ }
508
+
509
+ function cleanupDrag() {
510
+ primaryStartedMin = false
511
+ primaryInEscape = false
512
+ primaryCanSnap = false
513
+ primaryMaxReached = 0
514
+ primaryIdx = -1
515
+ dragGutterIdx = -1
516
+ }
517
+
518
+ function handleGutterPointerDown(e: PointerEvent, handle: PaneHandle) {
519
+ if (e.button != null && e.button !== 0) return
520
+ const g = panes.indexOf(handle)
521
+ if (g < 0 || g >= panes.length - 1) return
522
+
523
+ // Defensive sweep: clear --active from every gutter before claiming this one.
524
+ for (const h of panes) {
525
+ const gel = internals.get(h)!.getGutterEl()
526
+ gel?.classList.remove("fluent-multi-splitter-gutter--active")
527
+ }
528
+
529
+ dragGutterIdx = g
530
+ primaryIdx = primaryNeighbour(g)
531
+ primarySign = (primaryIdx === g) ? 1 : -1
532
+ const primary = internals.get(panes[primaryIdx])!
533
+ const primaryHandle = panes[primaryIdx]
534
+ primaryStartedMin = primaryHandle.state.isMin
535
+ // If primary started railed, onWindowPointerMove only releases `isMin`
536
+ // when the drag goes outward (would grow the pane). Inward drag is
537
+ // fully inert — the rail stays put and the gutter doesn't move.
538
+ // Dblclick / rail body click still work as alternative expand gestures.
539
+ primaryInEscape = false
540
+ primaryCanSnap = !primaryStartedMin
541
+ everMoved = false
542
+ activePointerId = e.pointerId
543
+ dragStartCoord = e[clientCoord]
544
+ dragStartPrimary = primary.size
545
+ primaryMaxReached = dragStartPrimary
546
+
547
+ log("pointerdown gutter=" + g + " primary=" + primaryIdx + " sign=" + primarySign)
548
+
549
+ const gut = internals.get(handle)!.getGutterEl()
550
+ try { gut?.setPointerCapture(e.pointerId) } catch { /* iOS */ }
551
+ rootEl?.classList.add("fluent-multi-splitter--dragging")
552
+ gut?.classList.add("fluent-multi-splitter-gutter--active")
553
+ // Listen on window — pointermove can leave the gutter element during
554
+ // fast drags, especially when crossing iframe boundaries.
555
+ window.addEventListener("pointermove", onWindowPointerMove)
556
+ window.addEventListener("pointerup", onWindowPointerUp)
557
+ window.addEventListener("pointercancel", onWindowPointerUp)
558
+ e.preventDefault()
559
+ }
560
+
561
+ function handleGutterKeydown(e: KeyboardEvent, handle: PaneHandle) {
562
+ const g = panes.indexOf(handle)
563
+ if (g < 0 || g >= panes.length - 1) return
564
+ switch (e.key) {
565
+ case "ArrowLeft":
566
+ case "ArrowUp":
567
+ shiftPrimary(g, -step); e.preventDefault(); break
568
+ case "ArrowRight":
569
+ case "ArrowDown":
570
+ shiftPrimary(g, step); e.preventDefault(); break
571
+ case "Home": {
572
+ const p = primaryNeighbour(g)
573
+ setPrimaryTo(g, internals.get(panes[p])!.min); e.preventDefault(); break
574
+ }
575
+ case "End": {
576
+ const p = primaryNeighbour(g)
577
+ setPrimaryTo(g, internals.get(panes[p])!.max); e.preventDefault(); break
578
+ }
579
+ case "Enter":
580
+ case " ": {
581
+ const p = primaryNeighbour(g)
582
+ const other = p === g ? g + 1 : g
583
+ if (internals.get(panes[p])!.canMin) togglePane(p)
584
+ else if (internals.get(panes[other])!.canMin) togglePane(other)
585
+ e.preventDefault(); break
586
+ }
587
+ }
588
+ }
589
+
590
+ function handleGutterDblClick(e: MouseEvent, handle: PaneHandle) {
591
+ const g = panes.indexOf(handle)
592
+ if (g < 0 || g >= panes.length - 1) return
593
+ e.preventDefault()
594
+ const p = primaryNeighbour(g)
595
+ const other = p === g ? g + 1 : g
596
+ if (internals.get(panes[p])!.canMin) togglePane(p)
597
+ else if (internals.get(panes[other])!.canMin) togglePane(other)
598
+ }
599
+
600
+ function handlePaneClick(_e: MouseEvent, handle: PaneHandle) {
601
+ // Click on a railed pane → restore. Click on an expanded pane is a no-op
602
+ // here (consumers can listen to onclick on their own content normally).
603
+ const i = panes.indexOf(handle)
604
+ if (i < 0) return
605
+ if (handle.state.isMin) {
606
+ log("rail click i=" + i)
607
+ restorePane(i)
608
+ }
609
+ }
610
+
611
+ function shiftPrimary(g: number, gutterDelta: number) {
612
+ const primary = primaryNeighbour(g)
613
+ const primaryHandle = panes[primary]
614
+ if (primaryHandle.state.isMin) return
615
+ const sign = (primary === g) ? 1 : -1
616
+ setPrimaryTo(g, internals.get(primaryHandle)!.size + sign * gutterDelta)
617
+ }
618
+
619
+ function setPrimaryTo(g: number, newSize: number) {
620
+ const primary = primaryNeighbour(g)
621
+ const primaryHandle = panes[primary]
622
+ if (primaryHandle.state.isMin) return
623
+ const p = internals.get(primaryHandle)!
624
+ let target = newSize
625
+ if (target < p.min) target = p.min
626
+ if (target > p.max) target = p.max
627
+ p.size = target
628
+ const total = totalAvailable()
629
+ const absorbers = computeAbsorbers(g, primary)
630
+ clampToConstraints(total, pinnedForAbsorbers(absorbers))
631
+ let sum = 0
632
+ for (const h of panes) sum += internals.get(h)!.size
633
+ if (Math.abs(sum - total) > 0.5) {
634
+ p.size -= (sum - total)
635
+ if (p.size < p.min) p.size = p.min
636
+ if (p.size > p.max) p.size = p.max
637
+ }
638
+ applySizes()
639
+ }
640
+
641
+ function minimizePane(i: number) {
642
+ const handle = panes[i]
643
+ const p = internals.get(handle)!
644
+ if (!p.canMin || handle.state.isMin) return
645
+ if (nonMinCount() <= 1) return
646
+ log("minimizePane i=" + i)
647
+ handle.state.isMin = true
648
+ p.size = railSize
649
+ const pinned = panes.map(h => h.state.isMin)
650
+ clampToConstraints(totalAvailable(), pinned)
651
+ applySizes()
652
+ oncollapse?.({index: i, pane: p.el})
653
+ }
654
+
655
+ function restorePane(i: number) {
656
+ const handle = panes[i]
657
+ const p = internals.get(handle)!
658
+ if (!handle.state.isMin) return
659
+ // ACCORDION SWEEP: in accordion mode, restoring one auto-rails the
660
+ // other minimizable panes so exactly one stays open.
661
+ if (accordionActive) {
662
+ for (let j = 0; j < panes.length; j++) {
663
+ if (j !== i) {
664
+ const hj = panes[j]
665
+ const pj = internals.get(hj)!
666
+ if (pj.canMin && !hj.state.isMin) {
667
+ hj.state.isMin = true
668
+ pj.size = railSize
669
+ oncollapse?.({index: j, pane: pj.el})
670
+ }
671
+ }
672
+ }
673
+ }
674
+ handle.state.isMin = false
675
+ // Target: remembered size, never below min. Compute floor first so
676
+ // the deficit math reflects the actual restore amount.
677
+ let target = p.lastNonZero > 0 ? p.lastNonZero : Math.max(p.min, railSize * 4)
678
+ if (target < p.min) target = p.min
679
+ const total = totalAvailable()
680
+ let currentSum = 0
681
+ let fromOthers = 0
682
+ for (let j = 0; j < panes.length; j++) {
683
+ const pj = internals.get(panes[j])!
684
+ currentSum += pj.size
685
+ if (j !== i && !panes[j].state.isMin) fromOthers += pj.size - pj.min
686
+ }
687
+ const emptySpace = Math.max(0, total - currentSum)
688
+ const available = fromOthers + emptySpace
689
+ const deficit = target - p.size
690
+ if (deficit > available) target = p.size + available
691
+ log("restorePane i=" + i, {target, available, deficit, lastNonZero: p.lastNonZero})
692
+ p.size = target
693
+ let newSum = 0
694
+ for (const h of panes) newSum += internals.get(h)!.size
695
+ if (newSum > total + 0.5) {
696
+ const pinned = panes.map(h => h.state.isMin)
697
+ pinned[i] = true
698
+ clampToConstraints(total, pinned)
699
+ } else if (accordionActive) {
700
+ expandNonMinToFill()
701
+ }
702
+ applySizes()
703
+ onexpand?.({index: i, pane: p.el})
704
+ }
705
+
706
+ function togglePane(i: number) {
707
+ if (panes[i].state.isMin) restorePane(i)
708
+ else minimizePane(i)
709
+ }
710
+
711
+ // ---- Accordion mode ----
712
+ function requiredForAllExpanded(): number {
713
+ let sum = 0
714
+ for (const h of panes) sum += internals.get(h)!.min
715
+ const gapCount = 2 * (panes.length - 1)
716
+ return sum + gutterTotal() + (gapCount * gapPx()) + paddingPx()
717
+ }
718
+
719
+ function shouldBeAccordion(): boolean {
720
+ if (!rootEl) return false
721
+ let minimizable = 0
722
+ for (const h of panes) if (internals.get(h)!.canMin) minimizable++
723
+ if (minimizable < 2) return false
724
+ return rootEl[clientAxis] < requiredForAllExpanded()
725
+ }
726
+
727
+ function enterAccordion() {
728
+ if (accordionActive) return
729
+ accordionActive = true
730
+ rootEl?.classList.add("fluent-multi-splitter--accordion")
731
+ // Keep priority: non-minimizable > first currently-expanded minimizable > pane 0.
732
+ let keepIdx = -1
733
+ for (let i = 0; i < panes.length; i++) {
734
+ if (!internals.get(panes[i])!.canMin) { keepIdx = i; break }
735
+ }
736
+ if (keepIdx === -1) {
737
+ for (let i = 0; i < panes.length; i++) {
738
+ if (internals.get(panes[i])!.canMin && !panes[i].state.isMin) { keepIdx = i; break }
739
+ }
740
+ }
741
+ if (keepIdx === -1) keepIdx = 0
742
+ log("enterAccordion keepIdx=" + keepIdx)
743
+ for (let k = 0; k < panes.length; k++) {
744
+ if (k !== keepIdx) {
745
+ const h = panes[k]
746
+ const p = internals.get(h)!
747
+ if (p.canMin && !h.state.isMin) {
748
+ h.state.isMin = true
749
+ p.size = railSize
750
+ }
751
+ }
752
+ }
753
+ expandNonMinToFill()
754
+ applySizes()
755
+ }
756
+
757
+ function exitAccordion() {
758
+ if (!accordionActive) return
759
+ accordionActive = false
760
+ rootEl?.classList.remove("fluent-multi-splitter--accordion")
761
+ log("exitAccordion")
762
+ }
763
+
764
+ // In accordion mode the expanded pane(s) ignore `max` and consume all
765
+ // remaining space — mins are still respected.
766
+ function expandNonMinToFill() {
767
+ const total = totalAvailable()
768
+ let sum = 0
769
+ const flexable: number[] = []
770
+ let flexableWeight = 0
771
+ for (let i = 0; i < panes.length; i++) {
772
+ const p = internals.get(panes[i])!
773
+ sum += p.size
774
+ if (!panes[i].state.isMin) {
775
+ flexable.push(i)
776
+ flexableWeight += p.size > 0 ? p.size : 1
777
+ }
778
+ }
779
+ const diff = total - sum
780
+ if (flexable.length === 0 || Math.abs(diff) < 0.5) return
781
+ for (const idx of flexable) {
782
+ const p = internals.get(panes[idx])!
783
+ const weight = (p.size > 0 ? p.size : 1) / flexableWeight
784
+ let next = p.size + diff * weight
785
+ if (next < p.min) next = p.min
786
+ p.size = next
787
+ }
788
+ }
789
+
790
+ // ---------- Context ----------
791
+ const ctx: MultiSplitterContext = {
792
+ get orientation() { return orientation },
793
+ registerPane(opts: PaneRegistration): PaneHandle {
794
+ const handle = new PaneHandleImpl(nextHandleId++)
795
+ internals.set(handle, {
796
+ el: opts.el,
797
+ getGutterEl: opts.getGutterEl,
798
+ canMin: opts.canMin,
799
+ rawSize: opts.size,
800
+ rawMin: opts.min,
801
+ rawMax: opts.max,
802
+ size: 0,
803
+ min: 0,
804
+ max: 0,
805
+ lastNonZero: 0
138
806
  })
807
+ panes = [...panes, handle]
808
+ // Pane is flex-item; we drive size via flex-basis exclusively.
809
+ opts.el.style.flex = "0 0 auto"
810
+ scheduleInit()
811
+ return handle
812
+ },
813
+ unregisterPane(handle: PaneHandle) {
814
+ internals.delete(handle)
815
+ panes = panes.filter(p => p !== handle)
816
+ },
817
+ indexOf(handle: PaneHandle): number {
818
+ return panes.indexOf(handle)
819
+ },
820
+ isLast(handle: PaneHandle): boolean {
821
+ return panes.length > 0 && panes[panes.length - 1] === handle
822
+ },
823
+ isMinimized(handle: PaneHandle): boolean {
824
+ return handle.state.isMin
825
+ },
826
+ onGutterPointerDown: handleGutterPointerDown,
827
+ onGutterKeydown: handleGutterKeydown,
828
+ onGutterDblClick: handleGutterDblClick,
829
+ onPaneClick: handlePaneClick
830
+ }
831
+ setContext<MultiSplitterContext>("multi-splitter", ctx)
832
+
833
+ // ---------- Initial size resolution ----------
834
+ // Panes register one-by-one in onMount; we want to apply initial sizing
835
+ // AFTER all panes have registered (so totalAvailable is correct). Schedule
836
+ // a microtask + rAF: the microtask coalesces multiple registrations,
837
+ // rAF gives the browser a chance to lay out a 0-sized container (hidden
838
+ // parent / modal not yet open) before we clamp.
839
+ let initScheduled = false
840
+ let initApplied = false
841
+ function scheduleInit() {
842
+ if (initScheduled) return
843
+ initScheduled = true
844
+ queueMicrotask(() => {
845
+ requestAnimationFrame(() => {
846
+ if (panes.length < 2 || !rootEl) {
847
+ initScheduled = false
848
+ return
849
+ }
850
+ applyInitialSizes()
851
+ initScheduled = false
852
+ initApplied = true
853
+ })
854
+ })
855
+ }
856
+
857
+ function applyInitialSizes() {
858
+ if (!rootEl) return
859
+ const N = panes.length
860
+ const initialTotal = resolveConstraints()
861
+ const rootSizeRef = rootEl[clientAxis]
862
+ const saved = readStorage()
863
+
864
+ const explicitSum = {value: 0}
865
+ const unspecified: number[] = []
866
+ for (let p = 0; p < N; p++) {
867
+ const internal = internals.get(panes[p])!
868
+ const v = parseSize(internal.rawSize, rootSizeRef)
869
+ if (v == null) {
870
+ internal.size = 0
871
+ unspecified.push(p)
872
+ } else {
873
+ internal.size = v
874
+ explicitSum.value += v
875
+ }
876
+ }
877
+ const leftover = initialTotal - explicitSum.value
878
+ if (unspecified.length > 0) {
879
+ const share = leftover / unspecified.length
880
+ for (const u of unspecified) internals.get(panes[u])!.size = Math.max(0, share)
881
+ } else if (Math.abs(leftover) > 0.5) {
882
+ // All sized but don't sum — give delta to the last pane.
883
+ internals.get(panes[N - 1])!.size += leftover
884
+ }
885
+ for (const h of panes) {
886
+ const p = internals.get(h)!
887
+ p.lastNonZero = p.size
888
+ }
889
+
890
+ // Overlay saved state (forgiving: matching-index entries are trusted,
891
+ // extras ignored, missing slots fall through to attribute defaults).
892
+ const startupMinimized: boolean[] = new Array(N).fill(false)
893
+ if (saved?.sizes) {
894
+ for (let s = 0; s < Math.min(N, saved.sizes.length); s++) {
895
+ if (typeof saved.sizes[s] === "number") internals.get(panes[s])!.size = saved.sizes[s]
896
+ if (saved.lasts && typeof saved.lasts[s] === "number" && saved.lasts[s] > internals.get(panes[s])!.min) {
897
+ internals.get(panes[s])!.lastNonZero = saved.lasts[s]
898
+ }
899
+ if (saved.minimized?.[s]) startupMinimized[s] = !!internals.get(panes[s])!.canMin
900
+ }
901
+ }
902
+
903
+ // "At least one expanded pane" invariant.
904
+ let allMin = true
905
+ for (let sm = 0; sm < N; sm++) if (!startupMinimized[sm]) { allMin = false; break }
906
+ if (allMin) startupMinimized[0] = false
907
+
908
+ for (let m = 0; m < N; m++) {
909
+ if (startupMinimized[m]) {
910
+ panes[m].state.isMin = true
911
+ internals.get(panes[m])!.size = railSize
912
+ }
139
913
  }
914
+ const pinned = panes.map(h => h.state.isMin)
915
+ clampToConstraints(initialTotal, pinned)
916
+ applySizes({persist: false})
917
+ if (shouldBeAccordion()) enterAccordion()
918
+ setupResizeObserver()
919
+ setupToggleDelegation()
140
920
  }
141
921
 
142
- function handleMouseUp() {
143
- isResizing = false
144
- resizingIndex = -1
922
+ // ---------- Toggle delegation ----------
923
+ let toggleListenerInstalled = false
924
+ function setupToggleDelegation() {
925
+ if (toggleListenerInstalled || !rootEl) return
926
+ toggleListenerInstalled = true
927
+ rootEl.addEventListener("click", (e) => {
928
+ const target = e.target as HTMLElement | null
929
+ if (!target) return
930
+ const toggle = target.closest("[data-multisplitter-toggle]")
931
+ if (!toggle) return
932
+ // Only the nearest splitter ancestor handles its own toggles.
933
+ if (toggle.closest(".fluent-multi-splitter") !== rootEl) return
934
+ // Find which pane owns this toggle.
935
+ let paneEl: HTMLElement | null = null
936
+ for (const h of panes) {
937
+ if (internals.get(h)!.el.contains(toggle)) { paneEl = internals.get(h)!.el; break }
938
+ }
939
+ if (!paneEl) return
940
+ const idx = panes.findIndex(h => internals.get(h)!.el === paneEl)
941
+ if (idx < 0 || !internals.get(panes[idx])!.canMin) return
942
+ e.preventDefault()
943
+ e.stopPropagation()
944
+ togglePane(idx)
945
+ })
145
946
  }
146
947
 
147
- let computedStyle = $derived(
148
- [
149
- width && `width: ${width};`,
150
- height && `height: ${height};`,
151
- `--bar-size: ${barSize};`,
152
- style
153
- ]
154
- .filter(Boolean)
155
- .join(" ")
156
- )
157
- </script>
948
+ // ---------- ResizeObserver ----------
949
+ let resizeObserver: ResizeObserver | null = null
950
+ function setupResizeObserver() {
951
+ if (resizeObserver || !rootEl || typeof ResizeObserver === "undefined") return
952
+ let lastTotal = totalAvailable()
953
+ resizeObserver = new ResizeObserver(() => {
954
+ const total = resolveConstraints()
955
+ if (Math.abs(total - lastTotal) < 0.5) return
956
+ if (lastTotal <= 0) { lastTotal = total; return }
957
+ const wantAccordion = shouldBeAccordion()
958
+ if (wantAccordion && !accordionActive) {
959
+ enterAccordion()
960
+ lastTotal = total
961
+ return
962
+ }
963
+ if (!wantAccordion && accordionActive) exitAccordion()
964
+ // Scale non-min pool; min panes hold at railSize.
965
+ let oldNonMinSum = 0
966
+ let railBudget = 0
967
+ for (const h of panes) {
968
+ const p = internals.get(h)!
969
+ if (h.state.isMin) railBudget += p.size
970
+ else oldNonMinSum += p.size
971
+ }
972
+ const newNonMinTotal = total - railBudget
973
+ if (oldNonMinSum > 0 && newNonMinTotal > 0) {
974
+ const scale = newNonMinTotal / oldNonMinSum
975
+ for (const h of panes) {
976
+ if (!h.state.isMin) internals.get(h)!.size *= scale
977
+ }
978
+ }
979
+ if (accordionActive) expandNonMinToFill()
980
+ else clampToConstraints(total, panes.map(h => h.state.isMin))
981
+ applySizes({persist: false})
982
+ lastTotal = total
983
+ })
984
+ resizeObserver.observe(rootEl)
985
+ }
158
986
 
159
- <svelte:window onmousemove={handleMouseMove} onmouseup={handleMouseUp} />
987
+ onMount(() => {
988
+ return () => {
989
+ resizeObserver?.disconnect()
990
+ if (reflowTimer != null) clearTimeout(reflowTimer)
991
+ }
992
+ })
993
+
994
+ const computedStyle = $derived.by(() => {
995
+ const parts: string[] = []
996
+ if (width) parts.push(`width: ${width}`)
997
+ if (height) parts.push(`height: ${height}`)
998
+ if (style) parts.push(style)
999
+ return parts.join("; ")
1000
+ })
1001
+ </script>
160
1002
 
161
1003
  <div
162
- bind:this={element}
163
- class="fluent-multi-splitter {className}"
1004
+ bind:this={rootEl}
1005
+ class="fluent-multi-splitter fluent-multi-splitter--{orientation} {className}"
164
1006
  style={computedStyle}
165
- data-orientation={orientation}
166
1007
  >
167
1008
  {@render children?.()}
168
1009
  </div>
169
1010
 
170
1011
  <style>
1012
+ /* ===== Root container =====
1013
+ * Width fills the parent. Height is intentionally NOT 100% — the splitter
1014
+ * sizes to its tallest pane (via default flex `align-items: stretch`),
1015
+ * which means all panes end up equal height = tallest natural content
1016
+ * height. Consumers who want a fixed-area splitter (IDE-style) can pass
1017
+ * the `height` prop or wrap in a sized container.
1018
+ *
1019
+ * pa-splitter's original default was `height: 100%`, which forces every
1020
+ * consumer to give the splitter a sized parent. Content-driven is the
1021
+ * less surprising default for ad-hoc usage; fill-parent is one prop away. */
171
1022
  .fluent-multi-splitter {
172
1023
  display: flex;
173
1024
  width: 100%;
174
- height: 100%;
175
- position: relative;
1025
+ min-width: 0;
1026
+ min-height: 0;
176
1027
  overflow: hidden;
1028
+ box-sizing: border-box;
177
1029
  }
178
1030
 
179
- .fluent-multi-splitter[data-orientation="horizontal"] {
1031
+ .fluent-multi-splitter--horizontal {
180
1032
  flex-direction: row;
181
1033
  }
182
1034
 
183
- .fluent-multi-splitter[data-orientation="vertical"] {
1035
+ .fluent-multi-splitter--vertical {
184
1036
  flex-direction: column;
185
1037
  }
186
1038
 
187
- :global(.fluent-multi-splitter-bar) {
188
- background-color: var(--neutral-stroke-divider-rest, #e1dfdd);
189
- flex-shrink: 0;
190
- position: relative;
191
- cursor: ew-resize;
1039
+ /* While dragging: kill text selection and pointer events on pane content
1040
+ * (so dragging over an iframe / canvas doesn't get hijacked). Sibling
1041
+ * gutters also stop hover-firing as cursor passes over them during drag. */
1042
+ .fluent-multi-splitter--dragging {
192
1043
  user-select: none;
1044
+ }
1045
+ .fluent-multi-splitter--dragging :global(.fluent-multi-splitter-pane) {
1046
+ pointer-events: none;
1047
+ }
1048
+ .fluent-multi-splitter--dragging :global(.fluent-multi-splitter-gutter:not(.fluent-multi-splitter-gutter--active)) {
1049
+ pointer-events: none;
1050
+ }
1051
+ .fluent-multi-splitter--dragging :global(.fluent-multi-splitter-gutter:not(.fluent-multi-splitter-gutter--active):focus-visible) {
1052
+ box-shadow: none;
1053
+ }
1054
+
1055
+ /* Programmatic reflow window — added for ~250 ms after applySizes runs
1056
+ * outside a drag. Suppresses the gutter-hover flash that fires when a
1057
+ * gutter slides under the resting cursor during reflow. */
1058
+ .fluent-multi-splitter--reflowing {
1059
+ user-select: none;
1060
+ }
1061
+ .fluent-multi-splitter--reflowing :global(.fluent-multi-splitter-gutter) {
1062
+ transition: none;
1063
+ }
1064
+ .fluent-multi-splitter--reflowing :global(.fluent-multi-splitter-gutter:hover),
1065
+ .fluent-multi-splitter--reflowing :global(.fluent-multi-splitter-gutter:focus-visible) {
1066
+ background-color: transparent;
1067
+ box-shadow: none;
1068
+ }
1069
+
1070
+ /* ===== Pane ===== */
1071
+ /* Make the pane a flex column so direct `.card` children can flex-grow
1072
+ * to fill the pane height. Without this, the card sits at its natural
1073
+ * content height and leaves whitespace below in vertical splitters
1074
+ * (where the pane's height is driven by flex-basis from drag) and in
1075
+ * horizontal splitters with mixed-height content (where the pane is
1076
+ * stretched to match the tallest sibling).
1077
+ *
1078
+ * Note: `flex: 1 1 auto` (not `1 1 0`) keeps the card's natural content
1079
+ * height as the basis, so the splitter's content-driven sizing still
1080
+ * resolves correctly when no height prop is set. */
1081
+ :global(.fluent-multi-splitter-pane) {
193
1082
  display: flex;
194
- align-items: center;
195
- justify-content: center;
1083
+ flex-direction: column;
1084
+ overflow: auto;
1085
+ min-width: 0;
1086
+ min-height: 0;
1087
+ box-sizing: border-box;
196
1088
  }
197
1089
 
198
- :global(.fluent-multi-splitter[data-orientation="horizontal"] .fluent-multi-splitter-bar) {
199
- width: var(--bar-size, 6px);
200
- height: 100%;
201
- cursor: ew-resize;
1090
+ :global(.fluent-multi-splitter-pane:not(.fluent-multi-splitter-pane--minimized) > .card) {
1091
+ flex: 1 1 auto;
1092
+ min-height: 0;
202
1093
  }
203
1094
 
204
- :global(.fluent-multi-splitter[data-orientation="vertical"] .fluent-multi-splitter-bar) {
205
- height: var(--bar-size, 6px);
206
- width: 100%;
207
- cursor: ns-resize;
1095
+ :global(.fluent-multi-splitter-pane--minimized) {
1096
+ cursor: pointer;
1097
+ overflow: hidden;
208
1098
  }
209
1099
 
210
- :global(.fluent-multi-splitter-bar:hover) {
211
- background-color: var(--neutral-stroke-divider-hover, #c8c6c4);
1100
+ /* Rail-title rotation hook: any element marked [data-multisplitter-rail-title]
1101
+ * inside a minimized pane rotates to vertical writing. */
1102
+ :global(.fluent-multi-splitter-pane--minimized [data-multisplitter-rail-title]) {
1103
+ writing-mode: sideways-rl;
212
1104
  }
1105
+ :global(.fluent-multi-splitter-pane--minimized [data-multisplitter-rail-title] i),
1106
+ :global(.fluent-multi-splitter-pane--minimized [data-multisplitter-rail-title] svg) {
1107
+ writing-mode: initial;
1108
+ }
1109
+
1110
+ /* Auto-adaptation for Card: when a `.card` (the svelte-fluentui Card
1111
+ * component) is rendered inside a minimized pane, fill the rail and
1112
+ * treat its first child as the rail title — hide the rest. Mirrors
1113
+ * pa-splitter's `_cards.scss` integration so consumers using <Card>
1114
+ * don't need data-multisplitter-rail-title on their headings.
1115
+ *
1116
+ * Pane carries its own orientation class (--horizontal / --vertical)
1117
+ * set from its parent splitter's orientation via context, so nested
1118
+ * splitters with different orientations don't cross-contaminate styles
1119
+ * (an earlier version walked up to ANY orientation ancestor, which
1120
+ * picked up the outer splitter for nested-inner panes).
1121
+ *
1122
+ * Specificity: three classes on the rail pane + `.card` = (0,4,0),
1123
+ * beats Card.svelte's own `.card { padding }` rule (0,2,0). */
213
1124
 
214
- :global(.fluent-multi-splitter-bar span) {
1125
+ /* Common: hide everything in the rail card except the first child. */
1126
+ :global(.fluent-multi-splitter-pane.fluent-multi-splitter-pane--minimized .card > *) {
1127
+ display: none;
1128
+ }
1129
+ :global(.fluent-multi-splitter-pane.fluent-multi-splitter-pane--minimized .card > :first-child) {
215
1130
  display: block;
216
- width: 20px;
217
- height: 20px;
218
- cursor: pointer;
219
- background-color: var(--neutral-fill-rest, #ffffff);
220
- border: 1px solid var(--neutral-stroke-rest, #8a8886);
221
- border-radius: var(--fluent-border-radius-circle);
222
- margin: 2px;
1131
+ white-space: nowrap;
1132
+ margin: 0;
223
1133
  }
224
1134
 
225
- :global(.fluent-multi-splitter-bar span:hover) {
226
- background-color: var(--neutral-fill-hover, #f3f2f1);
1135
+ /* Horizontal-splitter rail (narrow + tall) — title rotated vertically.
1136
+ * Long-axis padding matches Card.svelte's default (`design-unit * 5px`,
1137
+ * = 20px) so the title's distance from the top edge stays consistent
1138
+ * between expanded and railed states — toggling minimize doesn't shift
1139
+ * the icon. Short axis stays narrow to fit the 40px rail. */
1140
+ :global(.fluent-multi-splitter-pane--horizontal.fluent-multi-splitter-pane--minimized .card) {
1141
+ display: flex;
1142
+ flex-direction: column;
1143
+ align-items: center;
1144
+ justify-content: flex-start;
1145
+ width: 100%;
1146
+ height: 100%;
1147
+ box-sizing: border-box;
1148
+ padding: calc(var(--design-unit, 4) * 5px) calc(var(--design-unit, 4) * 1px);
1149
+ margin: 0;
1150
+ overflow: hidden;
1151
+ }
1152
+ :global(.fluent-multi-splitter-pane--horizontal.fluent-multi-splitter-pane--minimized .card > :first-child) {
1153
+ writing-mode: sideways-rl;
1154
+ }
1155
+ :global(.fluent-multi-splitter-pane--horizontal.fluent-multi-splitter-pane--minimized .card > :first-child i),
1156
+ :global(.fluent-multi-splitter-pane--horizontal.fluent-multi-splitter-pane--minimized .card > :first-child svg) {
1157
+ writing-mode: initial;
227
1158
  }
228
1159
 
229
- :global(.fluent-multi-splitter-pane) {
230
- flex-shrink: 0;
231
- overflow: auto;
1160
+ /* Vertical-splitter rail (wide + short) — title stays horizontal.
1161
+ * Long-axis padding matches Card.svelte's default so the title's distance
1162
+ * from the left edge is consistent across expanded/railed states. */
1163
+ :global(.fluent-multi-splitter-pane--vertical.fluent-multi-splitter-pane--minimized .card) {
1164
+ display: flex;
1165
+ flex-direction: row;
1166
+ align-items: center;
1167
+ justify-content: flex-start;
1168
+ width: 100%;
1169
+ height: 100%;
232
1170
  box-sizing: border-box;
1171
+ padding: calc(var(--design-unit, 4) * 1px) calc(var(--design-unit, 4) * 5px);
1172
+ margin: 0;
1173
+ overflow: hidden;
1174
+ }
1175
+
1176
+ /* ===== Gutter ===== */
1177
+ :global(.fluent-multi-splitter-gutter) {
1178
+ position: relative;
1179
+ flex: 0 0 auto;
1180
+ background-color: var(--neutral-stroke-divider-rest, #e0e0e0);
1181
+ transition: background-color 0.1s ease, box-shadow 0.1s ease;
1182
+ touch-action: none;
1183
+ outline: none;
1184
+ }
1185
+
1186
+ .fluent-multi-splitter--horizontal :global(> .fluent-multi-splitter-gutter) {
1187
+ cursor: col-resize;
1188
+ width: var(--fluent-multi-splitter-gutter-size, 6px);
1189
+ height: auto;
1190
+ align-self: stretch;
1191
+ }
1192
+
1193
+ .fluent-multi-splitter--vertical :global(> .fluent-multi-splitter-gutter) {
1194
+ cursor: row-resize;
1195
+ width: auto;
1196
+ height: var(--fluent-multi-splitter-gutter-size, 6px);
1197
+ align-self: stretch;
1198
+ }
1199
+
1200
+ /* Center grip indicator. */
1201
+ :global(.fluent-multi-splitter-gutter::before) {
1202
+ content: "";
1203
+ position: absolute;
1204
+ top: 50%;
1205
+ left: 50%;
1206
+ transform: translate(-50%, -50%);
1207
+ background-color: var(--neutral-stroke-rest, #d1d1d1);
1208
+ border-radius: 2px;
1209
+ transition: background-color 0.1s ease;
233
1210
  }
234
1211
 
235
- :global(.fluent-multi-splitter-pane[data-status="collapsed"]) {
236
- flex-basis: 0 !important;
237
- min-width: 0 !important;
238
- min-height: 0 !important;
239
- overflow: hidden !important;
1212
+ .fluent-multi-splitter--horizontal :global(> .fluent-multi-splitter-gutter::before) {
1213
+ width: 2px;
1214
+ height: 24px;
1215
+ }
1216
+ .fluent-multi-splitter--vertical :global(> .fluent-multi-splitter-gutter::before) {
1217
+ width: 24px;
1218
+ height: 2px;
1219
+ }
1220
+
1221
+ :global(.fluent-multi-splitter-gutter:hover) {
1222
+ background-color: var(--accent-fill-rest, #0078d4);
1223
+ }
1224
+ :global(.fluent-multi-splitter-gutter:hover::before) {
1225
+ background-color: var(--neutral-foreground-on-accent-rest, #ffffff);
1226
+ }
1227
+ :global(.fluent-multi-splitter-gutter:focus-visible) {
1228
+ box-shadow: inset 0 0 0 2px var(--accent-fill-rest, #0078d4);
1229
+ }
1230
+ :global(.fluent-multi-splitter-gutter--active) {
1231
+ background-color: var(--accent-fill-rest, #0078d4) !important;
1232
+ }
1233
+ :global(.fluent-multi-splitter-gutter--active::before) {
1234
+ background-color: var(--neutral-foreground-on-accent-rest, #ffffff);
240
1235
  }
241
1236
  </style>