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.
- package/README.md +8 -9
- package/dist/components/Accordion.svelte +1 -1
- package/dist/components/AccordionItem.svelte +7 -3
- package/dist/components/Anchor.svelte +12 -12
- package/dist/components/BreadcrumbItem.svelte +1 -1
- package/dist/components/Button.svelte +16 -16
- package/dist/components/Checkbox.svelte +10 -10
- package/dist/components/Combobox.svelte +13 -13
- package/dist/components/DataGrid.svelte +4 -4
- package/dist/components/DataGridCell.svelte +8 -8
- package/dist/components/DataGridRow.svelte +3 -3
- package/dist/components/Dialog.svelte +5 -5
- package/dist/components/Listbox.svelte +3 -3
- package/dist/components/MenuButton.svelte +3 -3
- package/dist/components/NumberField.svelte +15 -15
- package/dist/components/Option.svelte +6 -6
- package/dist/components/Paginator.svelte +4 -4
- package/dist/components/QuickGrid.svelte +1 -1
- package/dist/components/Select.svelte +13 -0
- package/dist/components/Slider.svelte +12 -12
- package/dist/components/Switch.svelte +8 -8
- package/dist/components/TabPanel.svelte +1 -1
- package/dist/components/TextField.svelte +14 -14
- package/dist/components/Textarea.svelte +16 -16
- package/dist/components/Toolbar.svelte +3 -3
- package/dist/components/layout/MultiSplitter.svelte +1152 -157
- package/dist/components/layout/MultiSplitter.svelte.d.ts +19 -9
- package/dist/components/layout/MultiSplitterPane.svelte +78 -99
- package/dist/components/layout/MultiSplitterPane.svelte.d.ts +7 -4
- package/dist/components/layout/index.d.ts +1 -0
- package/dist/types/multi-splitter.d.ts +35 -0
- package/dist/types/multi-splitter.js +9 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
117
|
-
if (!
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
1031
|
+
.fluent-multi-splitter--horizontal {
|
|
180
1032
|
flex-direction: row;
|
|
181
1033
|
}
|
|
182
1034
|
|
|
183
|
-
.fluent-multi-splitter
|
|
1035
|
+
.fluent-multi-splitter--vertical {
|
|
184
1036
|
flex-direction: column;
|
|
185
1037
|
}
|
|
186
1038
|
|
|
187
|
-
:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
height:
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
cursor: ns-resize;
|
|
1095
|
+
:global(.fluent-multi-splitter-pane--minimized) {
|
|
1096
|
+
cursor: pointer;
|
|
1097
|
+
overflow: hidden;
|
|
208
1098
|
}
|
|
209
1099
|
|
|
210
|
-
:
|
|
211
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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>
|