@stacksjs/components 0.2.119 → 0.2.120
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/dist/{Sidebar-hjazqxv8.stx → Sidebar-vgckg8f7.stx} +185 -33
- package/dist/{SidebarFooter-0aegr99p.stx → SidebarFooter-s7skj92y.stx} +46 -0
- package/dist/{SidebarHeader-ed5wb13t.stx → SidebarHeader-sa9mtdst.stx} +94 -1
- package/dist/SidebarPinned-tvj28bm0.stx +88 -0
- package/dist/SidebarSpace-61b662jx.stx +134 -0
- package/dist/SidebarSpaceSwitcher-882njcxq.stx +98 -0
- package/dist/SidebarSpaces-gq3mx984.stx +560 -0
- package/dist/index.js +164 -7
- package/dist/ui/sidebar/index.d.ts +17 -30
- package/dist/ui/sidebar/rows.d.ts +43 -0
- package/dist/ui/sidebar/spaces.d.ts +105 -0
- package/dist/ui/sidebar/themes.d.ts +5 -0
- package/package.json +3 -3
- package/src/ui/sidebar/Sidebar.stx +185 -33
- package/src/ui/sidebar/SidebarFooter.stx +46 -0
- package/src/ui/sidebar/SidebarHeader.stx +94 -1
- package/src/ui/sidebar/SidebarPinned.stx +88 -0
- package/src/ui/sidebar/SidebarSpace.stx +134 -0
- package/src/ui/sidebar/SidebarSpaceSwitcher.stx +98 -0
- package/src/ui/sidebar/SidebarSpaces.stx +560 -0
- package/src/ui/sidebar/index.ts +38 -42
- package/src/ui/sidebar/rows.ts +55 -0
- package/src/ui/sidebar/spaces.ts +260 -0
- package/src/ui/sidebar/themes.ts +108 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
<script server>
|
|
2
|
+
import { normalizeSpaces, spaceIndexOf } from './spaces'
|
|
3
|
+
|
|
4
|
+
// Arc's swipeable spaces.
|
|
5
|
+
//
|
|
6
|
+
// Every space is rendered up front, side by side on one track that is
|
|
7
|
+
// `n × 100%` wide; switching is a transform on the track rather than a
|
|
8
|
+
// re-render. That is what makes the gesture feel native — the next space is
|
|
9
|
+
// already painted, so it tracks the finger at 1:1 instead of appearing after
|
|
10
|
+
// the gesture ends. It also means each space keeps its own scroll position,
|
|
11
|
+
// its own disclosure state and its own DOM, exactly like Arc.
|
|
12
|
+
export const spaceList = normalizeSpaces($props.spaces || [])
|
|
13
|
+
export const theme = $props.theme || 'arc'
|
|
14
|
+
|
|
15
|
+
// `space` names the space to open on. `active` is accepted as an alias so the
|
|
16
|
+
// prop reads naturally next to `<SidebarSpaceSwitcher :active>`.
|
|
17
|
+
export const activeId = $props.space || $props.active || (spaceList[0] && spaceList[0].id) || ''
|
|
18
|
+
export const activeIndex = spaceIndexOf(spaceList, activeId)
|
|
19
|
+
|
|
20
|
+
export const showSwitcher = $props.showSwitcher ?? true
|
|
21
|
+
export const showAdd = $props.showAdd ?? false
|
|
22
|
+
export const addLabel = $props.addLabel || 'New space'
|
|
23
|
+
export const persistKey = $props.persistKey || ''
|
|
24
|
+
|
|
25
|
+
// One panel is `100 / n` percent of the track, so the server-rendered
|
|
26
|
+
// transform matches exactly what the controller computes on the client and the
|
|
27
|
+
// track does not jump on hydration.
|
|
28
|
+
const panelCount = Math.max(1, spaceList.length)
|
|
29
|
+
export const trackWidth = panelCount * 100
|
|
30
|
+
export const initialShift = activeIndex * (100 / panelCount)
|
|
31
|
+
|
|
32
|
+
// Everything the controller needs, in one attribute.
|
|
33
|
+
//
|
|
34
|
+
// It travels as data rather than as interpolated constants in the client
|
|
35
|
+
// script on purpose: `<script client>` blocks are bundled and cached by
|
|
36
|
+
// content, so baking per-instance values into one would either bust that cache
|
|
37
|
+
// on every render or — worse, once a bundle is reused — hand a second sidebar
|
|
38
|
+
// the first one's configuration. Rows and sections are already in the DOM, so
|
|
39
|
+
// only each space's identity and palette have to ship here; those are what a
|
|
40
|
+
// swipe repaints.
|
|
41
|
+
export const config = JSON.stringify({
|
|
42
|
+
persistKey,
|
|
43
|
+
index: activeIndex,
|
|
44
|
+
spaces: spaceList.map(space => ({
|
|
45
|
+
id: space.id,
|
|
46
|
+
label: space.label,
|
|
47
|
+
icon: space.icon,
|
|
48
|
+
tint: space.tint,
|
|
49
|
+
})),
|
|
50
|
+
})
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<script client>
|
|
54
|
+
const emit = defineEmits()
|
|
55
|
+
const componentRoot = window.__STX_CURRENT_ELEMENT__
|
|
56
|
+
|
|
57
|
+
const index = state(0)
|
|
58
|
+
|
|
59
|
+
let root = null
|
|
60
|
+
let viewport = null
|
|
61
|
+
let track = null
|
|
62
|
+
let persistKey = ''
|
|
63
|
+
let ids = []
|
|
64
|
+
let tints = []
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Geometry
|
|
68
|
+
//
|
|
69
|
+
// Position is measured in *panels*, not pixels: 1.5 means halfway between the
|
|
70
|
+
// second and third space. Keeping the gesture in panel units makes every
|
|
71
|
+
// threshold below independent of how wide the sidebar happens to be, so a
|
|
72
|
+
// 220px pane and a 320px pane need the same flick to advance.
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const SETTLE_MS = 420
|
|
76
|
+
const SETTLE_EASE = 'cubic-bezier(0.22, 0.61, 0.36, 1)'
|
|
77
|
+
// Distance past the first/last space is compressed towards this asymptote, the
|
|
78
|
+
// same thing UIScrollView does at the end of a list: the track keeps answering
|
|
79
|
+
// the finger, but tells you there is nothing further.
|
|
80
|
+
const RUBBER_LIMIT = 0.28
|
|
81
|
+
// Commit thresholds. Either drag a third of the way, or flick — 1.5 panels per
|
|
82
|
+
// second — in the direction you were already going.
|
|
83
|
+
const COMMIT_DISTANCE = 0.3
|
|
84
|
+
const COMMIT_VELOCITY = 0.0015
|
|
85
|
+
|
|
86
|
+
function panelStep() {
|
|
87
|
+
return 100 / Math.max(1, ids.length)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function paint(offset, animate) {
|
|
91
|
+
track.style.transition = animate ? `transform ${SETTLE_MS}ms ${SETTLE_EASE}` : 'none'
|
|
92
|
+
track.style.transform = `translate3d(${-offset * panelStep()}%, 0, 0)`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rubber(distance) {
|
|
96
|
+
return (1 - 1 / (distance / RUBBER_LIMIT + 1)) * RUBBER_LIMIT
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function withResistance(offset) {
|
|
100
|
+
const last = ids.length - 1
|
|
101
|
+
if (offset < 0) return -rubber(-offset)
|
|
102
|
+
if (offset > last) return last + rubber(offset - last)
|
|
103
|
+
return offset
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function clampIndex(value) {
|
|
107
|
+
return Math.max(0, Math.min(ids.length - 1, value))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function animates() {
|
|
111
|
+
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Painting the space
|
|
116
|
+
//
|
|
117
|
+
// The palette lands on the Sidebar pane rather than on this component, because
|
|
118
|
+
// Arc tints the *whole* sidebar — search field, rows and rail together. Used
|
|
119
|
+
// standalone, the component's own root takes the color instead.
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function tintTarget() {
|
|
123
|
+
return root.closest('[data-stx-sidebar]') || root
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyTint(position) {
|
|
127
|
+
const tint = tints[position]
|
|
128
|
+
if (!tint) return
|
|
129
|
+
const style = tintTarget().style
|
|
130
|
+
style.setProperty('--stx-space-light-from', tint.light.from)
|
|
131
|
+
style.setProperty('--stx-space-light-to', tint.light.to)
|
|
132
|
+
style.setProperty('--stx-space-light-ink', tint.light.ink)
|
|
133
|
+
style.setProperty('--stx-space-light-accent', tint.light.accent)
|
|
134
|
+
style.setProperty('--stx-space-dark-from', tint.dark.from)
|
|
135
|
+
style.setProperty('--stx-space-dark-to', tint.dark.to)
|
|
136
|
+
style.setProperty('--stx-space-dark-ink', tint.dark.ink)
|
|
137
|
+
style.setProperty('--stx-space-dark-accent', tint.dark.accent)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function markCurrent(position) {
|
|
141
|
+
const id = ids[position]
|
|
142
|
+
|
|
143
|
+
root.querySelectorAll('[data-space-target]').forEach((tab) => {
|
|
144
|
+
const current = tab.dataset.spaceTarget === id
|
|
145
|
+
tab.setAttribute('data-space-current', String(current))
|
|
146
|
+
tab.setAttribute('aria-selected', String(current))
|
|
147
|
+
tab.tabIndex = current ? 0 : -1
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
root.querySelectorAll('[data-space]').forEach((panel) => {
|
|
151
|
+
const current = panel.dataset.spaceId === id
|
|
152
|
+
panel.setAttribute('data-space-active', String(current))
|
|
153
|
+
// Off-screen spaces leave the tab order entirely. Without this, Tab walks
|
|
154
|
+
// through every row of every space the user cannot see — with hundreds of
|
|
155
|
+
// rows across a handful of spaces, the keyboard becomes unusable.
|
|
156
|
+
panel.inert = !current
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function persist(position) {
|
|
161
|
+
if (!persistKey) return
|
|
162
|
+
try { window.localStorage?.setItem(persistKey, ids[position]) } catch {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function restored() {
|
|
166
|
+
if (!persistKey) return -1
|
|
167
|
+
try {
|
|
168
|
+
const saved = window.localStorage?.getItem(persistKey)
|
|
169
|
+
return saved ? ids.indexOf(saved) : -1
|
|
170
|
+
}
|
|
171
|
+
catch { return -1 }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Move to a space. Always repaints — a rejected swipe still animates home. */
|
|
175
|
+
function goTo(next, options) {
|
|
176
|
+
const opts = options || {}
|
|
177
|
+
const target = clampIndex(next)
|
|
178
|
+
const changed = target !== index()
|
|
179
|
+
|
|
180
|
+
paint(target, opts.animate !== false && animates())
|
|
181
|
+
if (!changed) return
|
|
182
|
+
|
|
183
|
+
index.set(target)
|
|
184
|
+
applyTint(target)
|
|
185
|
+
markCurrent(target)
|
|
186
|
+
persist(target)
|
|
187
|
+
nativeSidebar?.setActiveSpace?.(ids[target])
|
|
188
|
+
emit('spaceChange', { id: ids[target], index: target, source: opts.source || 'api' })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Gestures
|
|
193
|
+
//
|
|
194
|
+
// All three input paths (wheel, pointer, native) funnel into the same two
|
|
195
|
+
// helpers: they track live with `paint(withResistance(offset), false)` while
|
|
196
|
+
// moving, then hand the final offset and velocity to `settle`.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
let gestureStart = 0
|
|
200
|
+
|
|
201
|
+
function beginGesture() {
|
|
202
|
+
gestureStart = index()
|
|
203
|
+
track.style.transition = 'none'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function settle(offset, velocity, source) {
|
|
207
|
+
const moved = offset - gestureStart
|
|
208
|
+
const direction = Math.sign(moved) || Math.sign(velocity)
|
|
209
|
+
const dragged = Math.abs(moved) > COMMIT_DISTANCE
|
|
210
|
+
const flicked = Math.abs(velocity) > COMMIT_VELOCITY && Math.sign(velocity) === direction
|
|
211
|
+
const commit = direction !== 0 && (dragged || flicked)
|
|
212
|
+
goTo(commit ? gestureStart + direction : gestureStart, { source })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- Trackpad ---------------------------------------------------------------
|
|
216
|
+
// A DOM wheel stream carries no phase information, so the gesture has to be
|
|
217
|
+
// inferred: claim it only when the first event is unambiguously horizontal,
|
|
218
|
+
// then end it after a short idle gap. Craft replaces this with the real
|
|
219
|
+
// NSEvent phases when it is available — see `bindCraftGestures`.
|
|
220
|
+
|
|
221
|
+
let wheelOffset = null
|
|
222
|
+
let wheelIdle = null
|
|
223
|
+
let wheelVelocity = 0
|
|
224
|
+
let wheelLastAt = 0
|
|
225
|
+
|
|
226
|
+
function onWheel(event) {
|
|
227
|
+
if (ids.length < 2) return
|
|
228
|
+
if (event.ctrlKey) return // pinch-zoom, not a swipe
|
|
229
|
+
|
|
230
|
+
if (wheelOffset === null) {
|
|
231
|
+
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return
|
|
232
|
+
beginGesture()
|
|
233
|
+
wheelOffset = gestureStart
|
|
234
|
+
wheelLastAt = event.timeStamp
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
event.preventDefault()
|
|
238
|
+
const delta = event.deltaX / (viewport.clientWidth || 1)
|
|
239
|
+
const elapsed = Math.max(1, event.timeStamp - wheelLastAt)
|
|
240
|
+
wheelLastAt = event.timeStamp
|
|
241
|
+
wheelVelocity = delta / elapsed
|
|
242
|
+
wheelOffset += delta
|
|
243
|
+
paint(withResistance(wheelOffset), false)
|
|
244
|
+
|
|
245
|
+
clearTimeout(wheelIdle)
|
|
246
|
+
wheelIdle = setTimeout(endWheel, 90)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function endWheel() {
|
|
250
|
+
if (wheelOffset === null) return
|
|
251
|
+
const offset = wheelOffset
|
|
252
|
+
wheelOffset = null
|
|
253
|
+
settle(offset, wheelVelocity, 'swipe')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Drop an in-flight wheel gesture without settling it. */
|
|
257
|
+
function cancelWheelGesture() {
|
|
258
|
+
clearTimeout(wheelIdle)
|
|
259
|
+
wheelOffset = null
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- Touch and pen ----------------------------------------------------------
|
|
263
|
+
// Mouse drags are deliberately left alone: in a sidebar, press-and-move is far
|
|
264
|
+
// more likely to be a text selection or a sloppy click than a swipe, and
|
|
265
|
+
// claiming it would break both.
|
|
266
|
+
|
|
267
|
+
let pointerId = null
|
|
268
|
+
let pointerStartX = 0
|
|
269
|
+
let pointerStartY = 0
|
|
270
|
+
let pointerOffset = null
|
|
271
|
+
let pointerLastX = 0
|
|
272
|
+
let pointerLastAt = 0
|
|
273
|
+
let pointerVelocity = 0
|
|
274
|
+
|
|
275
|
+
function onPointerDown(event) {
|
|
276
|
+
if (event.pointerType === 'mouse') return
|
|
277
|
+
if (ids.length < 2) return
|
|
278
|
+
pointerId = event.pointerId
|
|
279
|
+
pointerStartX = pointerLastX = event.clientX
|
|
280
|
+
pointerStartY = event.clientY
|
|
281
|
+
pointerLastAt = event.timeStamp
|
|
282
|
+
pointerOffset = null
|
|
283
|
+
pointerVelocity = 0
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function onPointerMove(event) {
|
|
287
|
+
if (event.pointerId !== pointerId) return
|
|
288
|
+
|
|
289
|
+
if (pointerOffset === null) {
|
|
290
|
+
// Axis lock. The first 8px of travel decides swipe or scroll, and the
|
|
291
|
+
// decision stands for the rest of the gesture — re-deciding mid-drag is
|
|
292
|
+
// what makes web carousels feel slippery.
|
|
293
|
+
const dx = event.clientX - pointerStartX
|
|
294
|
+
const dy = event.clientY - pointerStartY
|
|
295
|
+
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return
|
|
296
|
+
if (Math.abs(dx) <= Math.abs(dy)) { pointerId = null; return }
|
|
297
|
+
beginGesture()
|
|
298
|
+
pointerOffset = gestureStart
|
|
299
|
+
viewport.setPointerCapture(event.pointerId)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const delta = (pointerLastX - event.clientX) / (viewport.clientWidth || 1)
|
|
303
|
+
const elapsed = Math.max(1, event.timeStamp - pointerLastAt)
|
|
304
|
+
pointerLastX = event.clientX
|
|
305
|
+
pointerLastAt = event.timeStamp
|
|
306
|
+
pointerVelocity = delta / elapsed
|
|
307
|
+
pointerOffset += delta
|
|
308
|
+
paint(withResistance(pointerOffset), false)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function onPointerUp(event) {
|
|
312
|
+
if (event.pointerId !== pointerId) return
|
|
313
|
+
const offset = pointerOffset
|
|
314
|
+
pointerId = null
|
|
315
|
+
pointerOffset = null
|
|
316
|
+
if (offset === null) return
|
|
317
|
+
settle(offset, pointerVelocity, 'swipe')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Pointer clicks and keyboard
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
function onClick(event) {
|
|
325
|
+
if (event.target.closest('[data-space-add]')) {
|
|
326
|
+
emit('spaceAdd')
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const tab = event.target.closest('[data-space-target]')
|
|
330
|
+
if (tab) goTo(ids.indexOf(tab.dataset.spaceTarget), { source: 'switcher' })
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function onRailKeyDown(event) {
|
|
334
|
+
// Standard tablist keys, active only while a rail button holds focus so they
|
|
335
|
+
// never shadow the Sidebar's own ←/→ disclosure handling on the rows.
|
|
336
|
+
if (!event.target.closest('[data-space-target]')) return
|
|
337
|
+
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
|
338
|
+
if (step === 0) return
|
|
339
|
+
event.preventDefault()
|
|
340
|
+
goTo(index() + step, { source: 'keyboard' })
|
|
341
|
+
root.querySelector('[data-space-target][data-space-current="true"]')?.focus()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function onShortcut(event) {
|
|
345
|
+
// Arc's own binding. Both modifiers are required so it cannot collide with
|
|
346
|
+
// browser history navigation or with the row disclosure keys.
|
|
347
|
+
if (!event.metaKey || !event.altKey) return
|
|
348
|
+
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
|
349
|
+
if (step === 0) return
|
|
350
|
+
event.preventDefault()
|
|
351
|
+
goTo(index() + step, { source: 'keyboard' })
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Craft
|
|
356
|
+
//
|
|
357
|
+
// Two independent integrations, both optional, both a no-op in a plain
|
|
358
|
+
// browser:
|
|
359
|
+
//
|
|
360
|
+
// gestures A WKWebView's wheel stream is still phase-less, but the host sees
|
|
361
|
+
// the real NSEvent. When Craft forwards it, begin/change/end
|
|
362
|
+
// supersede the idle-timeout heuristic above — the difference
|
|
363
|
+
// between "close enough" and indistinguishable from AppKit.
|
|
364
|
+
// nativeUI The host may want to own the switcher (a real segmented control
|
|
365
|
+
// in the window toolbar, a Touch Bar strip, a menu). We publish the
|
|
366
|
+
// space list to it and follow whatever it selects.
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
|
|
369
|
+
let nativeSidebar = null
|
|
370
|
+
const craftTeardown = []
|
|
371
|
+
|
|
372
|
+
function bindCraftGestures() {
|
|
373
|
+
const gestures = window.craft?.gestures
|
|
374
|
+
if (typeof gestures?.onSwipe !== 'function') return
|
|
375
|
+
|
|
376
|
+
let offset = null
|
|
377
|
+
const off = gestures.onSwipe((swipe) => {
|
|
378
|
+
if (!swipe || swipe.axis !== 'horizontal') return
|
|
379
|
+
|
|
380
|
+
if (swipe.phase === 'begin') {
|
|
381
|
+
// Take over from the wheel path. The bridge exposes `onSwipe`
|
|
382
|
+
// unconditionally but a host that does not implement gestures never
|
|
383
|
+
// emits, so the wheel listener stays bound and this is the only moment
|
|
384
|
+
// we know the native stream is real. Cancelling any in-flight wheel
|
|
385
|
+
// gesture here stops the two paths settling the same swipe twice.
|
|
386
|
+
cancelWheelGesture()
|
|
387
|
+
beginGesture()
|
|
388
|
+
offset = gestureStart
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
if (offset === null) return
|
|
392
|
+
|
|
393
|
+
if (swipe.phase === 'end') {
|
|
394
|
+
const settled = offset
|
|
395
|
+
offset = null
|
|
396
|
+
// Craft reports velocity in points per second; the thresholds here are
|
|
397
|
+
// panels per millisecond.
|
|
398
|
+
settle(settled, (swipe.velocityX || 0) / (viewport.clientWidth || 1) / 1000, 'swipe')
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
offset += (swipe.deltaX || 0) / (viewport.clientWidth || 1)
|
|
403
|
+
paint(withResistance(offset), false)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
if (typeof off === 'function') craftTeardown.push(off)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function bindCraftSidebar(spaces) {
|
|
410
|
+
const nativeUI = window.craft?.nativeUI
|
|
411
|
+
if (typeof nativeUI?.createSpacesSidebar !== 'function') return
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
nativeSidebar = nativeUI.createSpacesSidebar({
|
|
415
|
+
id: root.id || undefined,
|
|
416
|
+
spaces: spaces.map(space => ({ id: space.id, label: space.label, icon: space.icon })),
|
|
417
|
+
activeSpace: ids[index()],
|
|
418
|
+
})
|
|
419
|
+
nativeSidebar?.onSpaceChange?.((spaceId) => {
|
|
420
|
+
goTo(ids.indexOf(spaceId), { source: 'native' })
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
console.warn('[stx] Craft spaces sidebar unavailable', error)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
onMount(() => {
|
|
431
|
+
// `closest` first so the component works whether the scope element is the
|
|
432
|
+
// spaces root itself or an ancestor wrapper around it.
|
|
433
|
+
root = componentRoot?.closest?.('[data-stx-sidebar-spaces]')
|
|
434
|
+
|| componentRoot?.querySelector?.('[data-stx-sidebar-spaces]')
|
|
435
|
+
if (!root) return
|
|
436
|
+
viewport = root.querySelector('[data-space-viewport]')
|
|
437
|
+
track = root.querySelector('[data-space-track]')
|
|
438
|
+
if (!viewport || !track) return
|
|
439
|
+
|
|
440
|
+
const config = JSON.parse(root.dataset.spaceConfig || '{}')
|
|
441
|
+
const spaces = config.spaces || []
|
|
442
|
+
persistKey = config.persistKey || ''
|
|
443
|
+
ids = spaces.map(space => space.id)
|
|
444
|
+
tints = spaces.map(space => space.tint)
|
|
445
|
+
if (ids.length === 0) return
|
|
446
|
+
|
|
447
|
+
const startIndex = clampIndex(config.index || 0)
|
|
448
|
+
const saved = restored()
|
|
449
|
+
const initial = saved === -1 ? startIndex : saved
|
|
450
|
+
index.set(initial)
|
|
451
|
+
paint(initial, false)
|
|
452
|
+
applyTint(initial)
|
|
453
|
+
markCurrent(initial)
|
|
454
|
+
|
|
455
|
+
root.addEventListener('click', onClick)
|
|
456
|
+
root.addEventListener('keydown', onRailKeyDown)
|
|
457
|
+
window.addEventListener('keydown', onShortcut)
|
|
458
|
+
viewport.addEventListener('pointerdown', onPointerDown)
|
|
459
|
+
viewport.addEventListener('pointermove', onPointerMove)
|
|
460
|
+
viewport.addEventListener('pointerup', onPointerUp)
|
|
461
|
+
viewport.addEventListener('pointercancel', onPointerUp)
|
|
462
|
+
|
|
463
|
+
// Both stay bound. Native phases take over the moment one actually arrives.
|
|
464
|
+
viewport.addEventListener('wheel', onWheel, { passive: false })
|
|
465
|
+
bindCraftGestures()
|
|
466
|
+
bindCraftSidebar(spaces)
|
|
467
|
+
|
|
468
|
+
if (initial !== startIndex)
|
|
469
|
+
emit('spaceChange', { id: ids[initial], index: initial, source: 'restore' })
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
onDestroy(() => {
|
|
473
|
+
window.removeEventListener('keydown', onShortcut)
|
|
474
|
+
clearTimeout(wheelIdle)
|
|
475
|
+
craftTeardown.forEach(off => off())
|
|
476
|
+
nativeSidebar?.destroy?.()
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
defineExpose({
|
|
480
|
+
index,
|
|
481
|
+
/** Accepts an index or a space id — callers think in ids, the track in indexes. */
|
|
482
|
+
goTo: target => goTo(typeof target === 'number' ? target : ids.indexOf(target), { source: 'api' }),
|
|
483
|
+
next: () => goTo(index() + 1, { source: 'api' }),
|
|
484
|
+
previous: () => goTo(index() - 1, { source: 'api' }),
|
|
485
|
+
})
|
|
486
|
+
</script>
|
|
487
|
+
|
|
488
|
+
<div
|
|
489
|
+
data-stx-sidebar-spaces
|
|
490
|
+
data-space-config="{{ config }}"
|
|
491
|
+
class="relative flex min-h-0 flex-1 flex-col"
|
|
492
|
+
>
|
|
493
|
+
<div data-space-viewport class="stx-space-viewport relative min-h-0 flex-1 overflow-hidden">
|
|
494
|
+
<div
|
|
495
|
+
data-space-track
|
|
496
|
+
class="stx-space-track flex h-full"
|
|
497
|
+
style="width: {{ trackWidth }}%; transform: translate3d(-{{ initialShift }}%, 0, 0)"
|
|
498
|
+
>
|
|
499
|
+
@foreach(spaceList as space)
|
|
500
|
+
<SidebarSpace
|
|
501
|
+
:id="space.id"
|
|
502
|
+
:label="space.label"
|
|
503
|
+
:icon="space.icon"
|
|
504
|
+
:pinned="space.pinned"
|
|
505
|
+
:sections="space.sections"
|
|
506
|
+
:action="space.action"
|
|
507
|
+
:clear="space.clear"
|
|
508
|
+
:active="space.id === activeId"
|
|
509
|
+
:theme="theme"
|
|
510
|
+
/>
|
|
511
|
+
@endforeach
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
@if(showSwitcher)
|
|
516
|
+
<SidebarSpaceSwitcher
|
|
517
|
+
:spaces="spaceList"
|
|
518
|
+
:active="activeId"
|
|
519
|
+
:showAdd="showAdd"
|
|
520
|
+
:addLabel="addLabel"
|
|
521
|
+
/>
|
|
522
|
+
@endif
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<style scoped>
|
|
526
|
+
.stx-space-viewport {
|
|
527
|
+
/* Vertical scrolling stays with the browser; horizontal belongs to us. */
|
|
528
|
+
touch-action: pan-y;
|
|
529
|
+
/* Stops a horizontal swipe from escaping into the browser's back gesture
|
|
530
|
+
once the track hits its first or last space. */
|
|
531
|
+
overscroll-behavior-x: contain;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.stx-space-track {
|
|
535
|
+
will-change: transform;
|
|
536
|
+
/* The track is composited on its own layer, so a swipe never repaints the
|
|
537
|
+
rows it is dragging. */
|
|
538
|
+
backface-visibility: hidden;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* Panels are sized from the track, not from themselves.
|
|
542
|
+
*
|
|
543
|
+
* A component with a client script renders inside a scope wrapper, so the
|
|
544
|
+
* track's direct children are those wrappers rather than the `<section>` each
|
|
545
|
+
* space defines — utility classes on the section would style the wrong box and
|
|
546
|
+
* every panel would collapse to its content width. Sizing the children is
|
|
547
|
+
* agnostic to whether a wrapper is there. With the track at `n × 100%` and n
|
|
548
|
+
* equal children, each panel lands at exactly one viewport wide.
|
|
549
|
+
*
|
|
550
|
+
* `script` and `style` must be excluded, not merely ignored: each space also
|
|
551
|
+
* emits its scoped stylesheet and its setup script as siblings here, and
|
|
552
|
+
* setting `display` on those overrides the user-agent's `display: none` — the
|
|
553
|
+
* sidebar renders its own source code as columns of text. */
|
|
554
|
+
.stx-space-track > *:not(script):not(style) {
|
|
555
|
+
flex: 1 1 0;
|
|
556
|
+
min-width: 0;
|
|
557
|
+
display: flex;
|
|
558
|
+
flex-direction: column;
|
|
559
|
+
}
|
|
560
|
+
</style>
|