@stacksjs/components 0.2.118 → 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.
@@ -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>