@stacksjs/components 0.2.154 → 0.2.156

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,163 @@
1
+ <script server>
2
+ export const action = $props.action || ''
3
+ export const method = $props.method || 'POST'
4
+ export const validationSchema = $props.validationSchema || {}
5
+ export const className = $props.className || ''
6
+ // These three were declared in FormProps and read by nothing, so passing them
7
+ // did exactly nothing. See #1874.
8
+ export const initialValues = $props.initialValues || {}
9
+ export const validateOnChange = $props.validateOnChange === true
10
+ export const validateOnBlur = $props.validateOnBlur === true
11
+
12
+ export const formClasses = `space-y-6 ${className}`.trim()
13
+ export const errorClasses = `mt-1 text-sm text-red-600 dark:text-red-400`.trim()
14
+ </script>
15
+
16
+ <script client>
17
+ const emit = defineEmits()
18
+ const validationSchema = {{ validationSchema }}
19
+ const initialValues = {{ initialValues }}
20
+ const validateOnChange = {{ validateOnChange }}
21
+ const validateOnBlur = {{ validateOnBlur }}
22
+
23
+ const errors = state({})
24
+ const isSubmitting = state(false)
25
+ const submitCount = state(0)
26
+ // Seeded from initialValues so the prop has an effect before the first submit,
27
+ // and so resetForm() has something to restore to.
28
+ const values = state({ ...initialValues })
29
+
30
+ function validateField(name, value, allValues) {
31
+ const schema = validationSchema[name]
32
+ if (!schema) return null
33
+ if (schema.required && !value) {
34
+ return schema.requiredMessage || `${name} is required`
35
+ }
36
+ if (schema.minLength && String(value).length < schema.minLength) {
37
+ return schema.minLengthMessage || `${name} must be at least ${schema.minLength} characters`
38
+ }
39
+ if (schema.maxLength && String(value).length > schema.maxLength) {
40
+ return schema.maxLengthMessage || `${name} must be at most ${schema.maxLength} characters`
41
+ }
42
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
43
+ return schema.patternMessage || `${name} is invalid`
44
+ }
45
+ // FieldSchema.validate has always been part of the published type and was
46
+ // never called, so a custom rule silently passed. It runs last, after the
47
+ // built-in rules, so it only sees values those already accepted.
48
+ if (typeof schema.validate === 'function') {
49
+ const custom = schema.validate(value, allValues || {})
50
+ if (custom) return custom
51
+ }
52
+ return null
53
+ }
54
+
55
+ // Single field, used by the change/blur paths so one bad field does not wipe the
56
+ // errors of the others the way a full validateForm() would.
57
+ function validateOne(name, value) {
58
+ if (!validationSchema[name]) return
59
+ const err = validateField(name, value, values())
60
+ const next = { ...errors() }
61
+ if (err) next[name] = err
62
+ else delete next[name]
63
+ errors.set(next)
64
+ }
65
+
66
+ // Delegated on the <form> rather than bound per input: the children arrive
67
+ // through <slot />, so the component never sees them individually. `input` and
68
+ // `focusout` both bubble (`blur` does not, which is why focusout is used).
69
+ function onFieldInput(event) {
70
+ const name = event.target?.name
71
+ if (!name) return
72
+ values.set({ ...values(), [name]: event.target.value })
73
+ if (validateOnChange) validateOne(name, event.target.value)
74
+ }
75
+
76
+ function onFieldBlur(event) {
77
+ const name = event.target?.name
78
+ if (!name) return
79
+ if (validateOnBlur) validateOne(name, event.target.value)
80
+ }
81
+
82
+ function validateForm(values) {
83
+ const next = {}
84
+ let valid = true
85
+ for (const name of Object.keys(validationSchema)) {
86
+ const err = validateField(name, values[name], values)
87
+ if (err) {
88
+ next[name] = err
89
+ valid = false
90
+ }
91
+ }
92
+ errors.set(next)
93
+ return valid
94
+ }
95
+
96
+ async function onSubmit(event) {
97
+ event.preventDefault()
98
+ submitCount.set(submitCount() + 1)
99
+
100
+ // initialValues supplies anything the form does not post (an untouched field
101
+ // with no input, a value held outside the DOM); FormData still wins where both
102
+ // have the key, so what the user actually typed is never overridden.
103
+ const formData = new FormData(event.target)
104
+ const submitted = { ...initialValues }
105
+ for (const [k, v] of formData.entries()) submitted[k] = v
106
+ values.set(submitted)
107
+
108
+ if (!validateForm(submitted)) {
109
+ emit('error', errors())
110
+ return
111
+ }
112
+
113
+ isSubmitting.set(true)
114
+ try {
115
+ // Second argument is FormHelpers, which the published type has always
116
+ // promised and which nothing used to pass.
117
+ emit('submit', submitted, helpers)
118
+ }
119
+ finally {
120
+ isSubmitting.set(false)
121
+ }
122
+ }
123
+
124
+ function setErrors(next) {
125
+ errors.set({ ...errors(), ...next })
126
+ }
127
+
128
+ function setValues(next) {
129
+ values.set({ ...values(), ...next })
130
+ }
131
+
132
+ function reset(form) {
133
+ errors.set({})
134
+ isSubmitting.set(false)
135
+ submitCount.set(0)
136
+ values.set({ ...initialValues })
137
+ if (form?.reset) form.reset()
138
+ }
139
+
140
+ // FormHelpers named setValues/resetForm; the component exposed setErrors/reset
141
+ // and no setValues at all, so the documented shape could not be destructured.
142
+ // Both spellings are exposed: resetForm/setValues to satisfy the type, reset to
143
+ // keep the existing call sites working.
144
+ function resetForm(form) {
145
+ reset(form)
146
+ }
147
+
148
+ const helpers = { setErrors, setValues, resetForm }
149
+
150
+ defineExpose({ errors, values, isSubmitting, submitCount, setErrors, setValues, reset, resetForm, validateForm, validateField })
151
+ </script>
152
+
153
+ <form
154
+ class="{{ formClasses }}"
155
+ @if(action)action="{{ action }}"@endif
156
+ method="{{ method }}"
157
+ @submit="onSubmit($event)"
158
+ @input="onFieldInput($event)"
159
+ @focusout="onFieldBlur($event)"
160
+ novalidate
161
+ >
162
+ <slot />
163
+ </form>
@@ -64,6 +64,7 @@ function onAction(event) {
64
64
  data-space
65
65
  data-space-id="{{ id }}"
66
66
  data-space-active="{{ active ? 'true' : 'false' }}"
67
+ @if(!active)inert@endif
67
68
  class="flex min-w-0 flex-1 flex-col"
68
69
  role="tabpanel"
69
70
  aria-label="{{ label || id }}"
@@ -51,6 +51,19 @@ export const config = JSON.stringify({
51
51
  </script>
52
52
 
53
53
  <script client>
54
+ import {
55
+ clampIndex,
56
+ nativeVelocityToPanels,
57
+ panelStep,
58
+ pointerAxis,
59
+ settleTarget,
60
+ SETTLE_EASE,
61
+ SETTLE_MS,
62
+ wheelClaims,
63
+ WHEEL_IDLE_MS,
64
+ withResistance,
65
+ } from './swipe'
66
+
54
67
  const emit = defineEmits()
55
68
  const componentRoot = window.__STX_CURRENT_ELEMENT__
56
69
 
@@ -72,39 +85,9 @@ let tints = []
72
85
  // 220px pane and a 320px pane need the same flick to advance.
73
86
  // ---------------------------------------------------------------------------
74
87
 
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
88
  function paint(offset, animate) {
91
89
  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))
90
+ track.style.transform = `translate3d(${-offset * panelStep(ids.length)}%, 0, 0)`
108
91
  }
109
92
 
110
93
  function animates() {
@@ -174,7 +157,7 @@ function restored() {
174
157
  /** Move to a space. Always repaints — a rejected swipe still animates home. */
175
158
  function goTo(next, options) {
176
159
  const opts = options || {}
177
- const target = clampIndex(next)
160
+ const target = clampIndex(next, ids.length)
178
161
  const changed = target !== index()
179
162
 
180
163
  paint(target, opts.animate !== false && animates())
@@ -192,8 +175,9 @@ function goTo(next, options) {
192
175
  // Gestures
193
176
  //
194
177
  // 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`.
178
+ // helpers: they track live with `paint(withResistance(…), false)` while moving,
179
+ // then hand the final offset and velocity to `settle`. The thresholds and the
180
+ // resistance curve live in `./swipe` so they can be tested directly.
197
181
  // ---------------------------------------------------------------------------
198
182
 
199
183
  let gestureStart = 0
@@ -204,12 +188,7 @@ function beginGesture() {
204
188
  }
205
189
 
206
190
  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 })
191
+ goTo(settleTarget(offset, gestureStart, velocity, ids.length), { source })
213
192
  }
214
193
 
215
194
  // --- Trackpad ---------------------------------------------------------------
@@ -222,13 +201,18 @@ let wheelOffset = null
222
201
  let wheelIdle = null
223
202
  let wheelVelocity = 0
224
203
  let wheelLastAt = 0
204
+ // Set the first time the host delivers a real gesture phase. From then on the
205
+ // wheel stream is the same physical events arriving a second time, so running
206
+ // the heuristic as well would drive the track twice and settle it twice.
207
+ let nativeGesturesConfirmed = false
225
208
 
226
209
  function onWheel(event) {
210
+ if (nativeGesturesConfirmed) return
227
211
  if (ids.length < 2) return
228
212
  if (event.ctrlKey) return // pinch-zoom, not a swipe
229
213
 
230
214
  if (wheelOffset === null) {
231
- if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return
215
+ if (!wheelClaims(event.deltaX, event.deltaY)) return
232
216
  beginGesture()
233
217
  wheelOffset = gestureStart
234
218
  wheelLastAt = event.timeStamp
@@ -240,10 +224,10 @@ function onWheel(event) {
240
224
  wheelLastAt = event.timeStamp
241
225
  wheelVelocity = delta / elapsed
242
226
  wheelOffset += delta
243
- paint(withResistance(wheelOffset), false)
227
+ paint(withResistance(wheelOffset, ids.length), false)
244
228
 
245
229
  clearTimeout(wheelIdle)
246
- wheelIdle = setTimeout(endWheel, 90)
230
+ wheelIdle = setTimeout(endWheel, WHEEL_IDLE_MS)
247
231
  }
248
232
 
249
233
  function endWheel() {
@@ -253,10 +237,17 @@ function endWheel() {
253
237
  settle(offset, wheelVelocity, 'swipe')
254
238
  }
255
239
 
256
- /** Drop an in-flight wheel gesture without settling it. */
240
+ /**
241
+ * Drop an in-flight wheel gesture without settling it, returning wherever it
242
+ * had dragged the track to. The caller adopts that offset so the handover to
243
+ * the native stream is invisible — resetting to the logical index instead
244
+ * would snap the track backwards at the exact moment the gesture gets better.
245
+ */
257
246
  function cancelWheelGesture() {
258
247
  clearTimeout(wheelIdle)
248
+ const carried = wheelOffset
259
249
  wheelOffset = null
250
+ return carried
260
251
  }
261
252
 
262
253
  // --- Touch and pen ----------------------------------------------------------
@@ -290,10 +281,9 @@ function onPointerMove(event) {
290
281
  // Axis lock. The first 8px of travel decides swipe or scroll, and the
291
282
  // decision stands for the rest of the gesture — re-deciding mid-drag is
292
283
  // 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 }
284
+ const axis = pointerAxis(event.clientX - pointerStartX, event.clientY - pointerStartY)
285
+ if (axis === 'undecided') return
286
+ if (axis === 'vertical') { pointerId = null; return }
297
287
  beginGesture()
298
288
  pointerOffset = gestureStart
299
289
  viewport.setPointerCapture(event.pointerId)
@@ -305,7 +295,7 @@ function onPointerMove(event) {
305
295
  pointerLastAt = event.timeStamp
306
296
  pointerVelocity = delta / elapsed
307
297
  pointerOffset += delta
308
- paint(withResistance(pointerOffset), false)
298
+ paint(withResistance(pointerOffset, ids.length), false)
309
299
  }
310
300
 
311
301
  function onPointerUp(event) {
@@ -347,10 +337,24 @@ function onShortcut(event) {
347
337
  if (!event.metaKey || !event.altKey) return
348
338
  const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
349
339
  if (step === 0) return
340
+ // Window-level, so a page with two spaces sidebars would otherwise advance
341
+ // both at once. Whichever one contains the focus owns the shortcut; with
342
+ // focus outside every sidebar the sole sidebar still answers, because that
343
+ // is the common case and requiring a click first would be surprising.
344
+ if (!ownsShortcut()) return
350
345
  event.preventDefault()
351
346
  goTo(index() + step, { source: 'keyboard' })
352
347
  }
353
348
 
349
+ function ownsShortcut() {
350
+ const focused = document.activeElement
351
+ const owner = focused && focused !== document.body
352
+ ? focused.closest('[data-stx-sidebar-spaces]')
353
+ : null
354
+ if (owner) return owner === root
355
+ return document.querySelectorAll('[data-stx-sidebar-spaces]').length === 1
356
+ }
357
+
354
358
  // ---------------------------------------------------------------------------
355
359
  // Craft
356
360
  //
@@ -381,11 +385,15 @@ function bindCraftGestures() {
381
385
  // Take over from the wheel path. The bridge exposes `onSwipe`
382
386
  // unconditionally but a host that does not implement gestures never
383
387
  // 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()
388
+ // we know the native stream is real. From here the heuristic is not just
389
+ // redundant but harmful — the same physical scroll arrives on both paths
390
+ // — so it is retired for good, not merely cancelled.
391
+ nativeGesturesConfirmed = true
392
+ const carried = cancelWheelGesture()
387
393
  beginGesture()
388
- offset = gestureStart
394
+ // Adopt whatever the wheel path had already dragged to, so the handover
395
+ // does not snap the track back to the logical index.
396
+ offset = carried === null ? gestureStart : carried
389
397
  return
390
398
  }
391
399
  if (offset === null) return
@@ -395,12 +403,12 @@ function bindCraftGestures() {
395
403
  offset = null
396
404
  // Craft reports velocity in points per second; the thresholds here are
397
405
  // panels per millisecond.
398
- settle(settled, (swipe.velocityX || 0) / (viewport.clientWidth || 1) / 1000, 'swipe')
406
+ settle(settled, nativeVelocityToPanels(swipe.velocityX || 0, viewport.clientWidth), 'swipe')
399
407
  return
400
408
  }
401
409
 
402
410
  offset += (swipe.deltaX || 0) / (viewport.clientWidth || 1)
403
- paint(withResistance(offset), false)
411
+ paint(withResistance(offset, ids.length), false)
404
412
  })
405
413
 
406
414
  if (typeof off === 'function') craftTeardown.push(off)
@@ -444,7 +452,7 @@ onMount(() => {
444
452
  tints = spaces.map(space => space.tint)
445
453
  if (ids.length === 0) return
446
454
 
447
- const startIndex = clampIndex(config.index || 0)
455
+ const startIndex = clampIndex(config.index || 0, ids.length)
448
456
  const saved = restored()
449
457
  const initial = saved === -1 ? startIndex : saved
450
458
  index.set(initial)
@@ -523,6 +531,57 @@ defineExpose({
523
531
  </div>
524
532
 
525
533
  <style scoped>
534
+ /* ---------------------------------------------------------------------------
535
+ * Standalone tinting.
536
+ *
537
+ * `applyTint` writes the palette onto `root.closest('[data-stx-sidebar]')` and
538
+ * falls back to this component's own root — the documented way to use
539
+ * `<SidebarSpaces>` without a `<Sidebar>` around it. But the rules that
540
+ * *consume* those variables live in Sidebar.stx, so the fallback target used to
541
+ * publish a palette nothing read: standalone spaces rendered with no gradient
542
+ * and no crossfade at all.
543
+ *
544
+ * These mirror Sidebar.stx's spaces block for the standalone case only. The
545
+ * `:not([data-stx-sidebar] *)` guard keeps them out of the nested case, where
546
+ * the pane is the thing that should be painted, not this inner column.
547
+ * ------------------------------------------------------------------------- */
548
+ @property --stx-space-from { syntax: "<color>"; inherits: true; initial-value: #f2f0ec; }
549
+ @property --stx-space-to { syntax: "<color>"; inherits: true; initial-value: #e6e3dd; }
550
+ @property --stx-space-ink { syntax: "<color>"; inherits: true; initial-value: #2c2a28; }
551
+ @property --stx-space-accent { syntax: "<color>"; inherits: true; initial-value: #5b5b63; }
552
+
553
+ [data-stx-sidebar-spaces]:not([data-stx-sidebar] *) {
554
+ --stx-space-from: var(--stx-space-light-from, #f2f0ec);
555
+ --stx-space-to: var(--stx-space-light-to, #e6e3dd);
556
+ --stx-space-ink: var(--stx-space-light-ink, #2c2a28);
557
+ --stx-space-accent: var(--stx-space-light-accent, #5b5b63);
558
+ background:
559
+ linear-gradient(180deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 32%),
560
+ linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
561
+ color: var(--stx-space-ink);
562
+ transition:
563
+ --stx-space-from 420ms ease,
564
+ --stx-space-to 420ms ease,
565
+ --stx-space-ink 420ms ease,
566
+ --stx-space-accent 420ms ease;
567
+ }
568
+
569
+ @media (prefers-color-scheme: dark) {
570
+ [data-stx-sidebar-spaces]:not([data-stx-sidebar] *) {
571
+ --stx-space-from: var(--stx-space-dark-from, #17171b);
572
+ --stx-space-to: var(--stx-space-dark-to, #0e0e11);
573
+ --stx-space-ink: var(--stx-space-dark-ink, #ededf0);
574
+ --stx-space-accent: var(--stx-space-dark-accent, #b9b9c2);
575
+ background:
576
+ linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 30%),
577
+ linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
578
+ }
579
+ }
580
+
581
+ @media (prefers-reduced-motion: reduce) {
582
+ [data-stx-sidebar-spaces]:not([data-stx-sidebar] *) { transition: none; }
583
+ }
584
+
526
585
  .stx-space-viewport {
527
586
  /* Vertical scrolling stays with the browser; horizontal belongs to us. */
528
587
  touch-action: pan-y;
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // @bun
2
- // ../../../../../../../tmp/stx-components-build-bwsTuP/stubs/stub-0.js
2
+ // ../../../../../../../tmp/stx-components-build-ghpOHJ/stubs/stub-0.js
3
3
  var stub_0_default = { __stx: true, src: "./components/CodeBlock.stx" };
4
- // ../../../../../../../tmp/stx-components-build-bwsTuP/stubs/stub-1.js
4
+ // ../../../../../../../tmp/stx-components-build-ghpOHJ/stubs/stub-1.js
5
5
  var stub_1_default = { __stx: true, src: "./components/Footer.stx" };
6
- // ../../../../../../../tmp/stx-components-build-bwsTuP/stubs/stub-2.js
6
+ // ../../../../../../../tmp/stx-components-build-ghpOHJ/stubs/stub-2.js
7
7
  var stub_2_default = { __stx: true, src: "./components/Hero.stx" };
8
- // ../../../../../../../tmp/stx-components-build-bwsTuP/stubs/stub-3.js
8
+ // ../../../../../../../tmp/stx-components-build-ghpOHJ/stubs/stub-3.js
9
9
  var stub_3_default = { __stx: true, src: "./components/Installation.stx" };
10
10
  // src/composables/useCopyCode.ts
11
11
  function useCopyCode(options) {
@@ -969,7 +969,7 @@ var DropdownItem_default = "./DropdownItem-c7rz08e5.stx";
969
969
  // src/ui/dropdown/DropdownItems.stx
970
970
  var DropdownItems_default = "./DropdownItems-1sxnd5qn.stx";
971
971
  // src/ui/form/Form.stx
972
- var Form_default = "./Form-mas630hy.stx";
972
+ var Form_default = "./Form-bpnpbymb.stx";
973
973
 
974
974
  // src/ui/form/index.ts
975
975
  var formPropSchema = {
@@ -1144,9 +1144,9 @@ var SidebarPinned_default = "./SidebarPinned-tvj28bm0.stx";
1144
1144
  // src/ui/sidebar/SidebarSection.stx
1145
1145
  var SidebarSection_default = "./SidebarSection-zd9z35ch.stx";
1146
1146
  // src/ui/sidebar/SidebarSpace.stx
1147
- var SidebarSpace_default = "./SidebarSpace-61b662jx.stx";
1147
+ var SidebarSpace_default = "./SidebarSpace-h50n47va.stx";
1148
1148
  // src/ui/sidebar/SidebarSpaces.stx
1149
- var SidebarSpaces_default = "./SidebarSpaces-gq3mx984.stx";
1149
+ var SidebarSpaces_default = "./SidebarSpaces-sy8dy0gb.stx";
1150
1150
  // src/ui/sidebar/SidebarSpaceSwitcher.stx
1151
1151
  var SidebarSpaceSwitcher_default = "./SidebarSpaceSwitcher-882njcxq.stx";
1152
1152
  // src/ui/sidebar/themes.ts
@@ -21,10 +21,18 @@ export declare interface FieldSchema {
21
21
  export declare interface ValidationSchema {
22
22
  [fieldName: string]: FieldSchema
23
23
  }
24
+ /**
25
+ * Passed as the second argument of the `submit` event.
26
+ *
27
+ * These names are the ones the component actually exposes. Before #1874 the type
28
+ * named `setValues` and `resetForm` while the component implemented `setErrors`
29
+ * and `reset` and had no `setValues` at all, so destructuring this shape gave you
30
+ * two undefined functions.
31
+ */
24
32
  export declare interface FormHelpers {
25
33
  setErrors: (errors: Record<string, string>) => void
26
34
  setValues: (values: Record<string, any>) => void
27
- resetForm: () => void
35
+ resetForm: (form?: HTMLFormElement) => void
28
36
  }
29
37
  export declare interface FormProps {
30
38
  action?: string
@@ -0,0 +1,67 @@
1
+ /** Width of one panel as a percentage of the whole track. */
2
+ export declare function panelStep(count: number): number;
3
+ /** The compression curve applied beyond either end of the track. */
4
+ export declare function rubber(distance: number): number;
5
+ /** Clamp an offset to the track, compressing rather than stopping at the ends. */
6
+ export declare function withResistance(offset: number, count: number): number;
7
+ export declare function clampIndex(value: number, count: number): number;
8
+ /**
9
+ * Where a released gesture lands.
10
+ *
11
+ * A swipe advances at most one space however far or fast it was dragged —
12
+ * Arc behaves the same way, and it keeps a fast flick from skipping past the
13
+ * space you were looking at.
14
+ */
15
+ export declare function settleTarget(offset: number, gestureStart: number, velocity: number, count: number): number;
16
+ /**
17
+ * Whether a wheel event should start a swipe.
18
+ *
19
+ * Claimed only when the very first event is unambiguously horizontal. A DOM
20
+ * wheel stream carries no phase information, so this is a guess — the native
21
+ * path in Craft replaces it with real `NSEvent` phases when the host provides
22
+ * them.
23
+ */
24
+ export declare function wheelClaims(deltaX: number, deltaY: number): boolean;
25
+ /**
26
+ * Axis lock for touch and pen. The first 8px of travel decides, and the
27
+ * decision stands for the rest of the gesture — re-deciding mid-drag is what
28
+ * makes web carousels feel slippery.
29
+ */
30
+ export declare function pointerAxis(dx: number, dy: number): PointerAxis;
31
+ /**
32
+ * Convert Craft's release velocity (points per second) into the panels per
33
+ * millisecond the commit thresholds are expressed in.
34
+ */
35
+ export declare function nativeVelocityToPanels(velocityX: number, viewportWidth: number): number;
36
+ /**
37
+ * The geometry and commit rules behind a spaces swipe.
38
+ *
39
+ * Extracted from `SidebarSpaces.stx` for the same reason `route-selection.ts`
40
+ * is: the component's client script cannot be imported, and this is the part
41
+ * worth pinning down. Everything here is pure — no DOM, no state — so the
42
+ * thresholds that decide whether a swipe advances can be asserted directly
43
+ * instead of inferred from a rendered pane.
44
+ *
45
+ * Position is measured in *panels*, not pixels: 1.5 means halfway between the
46
+ * second and third space. Keeping the gesture in panel units makes every
47
+ * threshold independent of how wide the sidebar happens to be, so a 220px pane
48
+ * and a 320px pane need the same flick to advance.
49
+ */
50
+ /** Settle animation, matched by the tint crossfade so colour and motion agree. */
51
+ export declare const SETTLE_MS: 420;
52
+ export declare const SETTLE_EASE: 'cubic-bezier(0.22, 0.61, 0.36, 1)';
53
+ /**
54
+ * Travel past the first/last space is compressed towards this asymptote, the
55
+ * same thing a UIScrollView does at the end of a list: the track keeps
56
+ * answering the finger, but tells you there is nothing further.
57
+ */
58
+ export declare const RUBBER_LIMIT: 0.28;
59
+ /** Drag this fraction of a panel and release, and the swipe commits. */
60
+ export declare const COMMIT_DISTANCE: 0.3;
61
+ /** Or flick: panels per millisecond, i.e. 1.5 panels/second. */
62
+ export declare const COMMIT_VELOCITY: 0.0015;
63
+ /** The first pointer travel that decides swipe-or-scroll, in CSS pixels. */
64
+ export declare const AXIS_LOCK_PX: 8;
65
+ /** A wheel gesture ends when the stream goes quiet for this long. */
66
+ export declare const WHEEL_IDLE_MS: 90;
67
+ export type PointerAxis = 'undecided' | 'horizontal' | 'vertical';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/components",
3
3
  "type": "module",
4
- "version": "0.2.154",
4
+ "version": "0.2.156",
5
5
  "description": "A modern component library built with STX, featuring headwind utility classes and TypeScript syntax highlighting.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -64,8 +64,8 @@
64
64
  "dependencies": {
65
65
  "@iconify-json/f7": "^1.2.2",
66
66
  "@cwcss/crosswind": "^0.2.14",
67
- "@stacksjs/stx": "0.2.153",
68
- "bun-plugin-stx": "0.2.153",
67
+ "@stacksjs/stx": "0.2.156",
68
+ "bun-plugin-stx": "0.2.156",
69
69
  "ts-syntax-highlighter": "^0.2.1"
70
70
  },
71
71
  "devDependencies": {
@@ -3,6 +3,11 @@ export const action = $props.action || ''
3
3
  export const method = $props.method || 'POST'
4
4
  export const validationSchema = $props.validationSchema || {}
5
5
  export const className = $props.className || ''
6
+ // These three were declared in FormProps and read by nothing, so passing them
7
+ // did exactly nothing. See #1874.
8
+ export const initialValues = $props.initialValues || {}
9
+ export const validateOnChange = $props.validateOnChange === true
10
+ export const validateOnBlur = $props.validateOnBlur === true
6
11
 
7
12
  export const formClasses = `space-y-6 ${className}`.trim()
8
13
  export const errorClasses = `mt-1 text-sm text-red-600 dark:text-red-400`.trim()
@@ -11,10 +16,16 @@ export const errorClasses = `mt-1 text-sm text-red-600 dark:text-red-400`.trim()
11
16
  <script client>
12
17
  const emit = defineEmits()
13
18
  const validationSchema = {{ validationSchema }}
19
+ const initialValues = {{ initialValues }}
20
+ const validateOnChange = {{ validateOnChange }}
21
+ const validateOnBlur = {{ validateOnBlur }}
14
22
 
15
23
  const errors = state({})
16
24
  const isSubmitting = state(false)
17
25
  const submitCount = state(0)
26
+ // Seeded from initialValues so the prop has an effect before the first submit,
27
+ // and so resetForm() has something to restore to.
28
+ const values = state({ ...initialValues })
18
29
 
19
30
  function validateField(name, value, allValues) {
20
31
  const schema = validationSchema[name]
@@ -31,9 +42,43 @@ function validateField(name, value, allValues) {
31
42
  if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
32
43
  return schema.patternMessage || `${name} is invalid`
33
44
  }
45
+ // FieldSchema.validate has always been part of the published type and was
46
+ // never called, so a custom rule silently passed. It runs last, after the
47
+ // built-in rules, so it only sees values those already accepted.
48
+ if (typeof schema.validate === 'function') {
49
+ const custom = schema.validate(value, allValues || {})
50
+ if (custom) return custom
51
+ }
34
52
  return null
35
53
  }
36
54
 
55
+ // Single field, used by the change/blur paths so one bad field does not wipe the
56
+ // errors of the others the way a full validateForm() would.
57
+ function validateOne(name, value) {
58
+ if (!validationSchema[name]) return
59
+ const err = validateField(name, value, values())
60
+ const next = { ...errors() }
61
+ if (err) next[name] = err
62
+ else delete next[name]
63
+ errors.set(next)
64
+ }
65
+
66
+ // Delegated on the <form> rather than bound per input: the children arrive
67
+ // through <slot />, so the component never sees them individually. `input` and
68
+ // `focusout` both bubble (`blur` does not, which is why focusout is used).
69
+ function onFieldInput(event) {
70
+ const name = event.target?.name
71
+ if (!name) return
72
+ values.set({ ...values(), [name]: event.target.value })
73
+ if (validateOnChange) validateOne(name, event.target.value)
74
+ }
75
+
76
+ function onFieldBlur(event) {
77
+ const name = event.target?.name
78
+ if (!name) return
79
+ if (validateOnBlur) validateOne(name, event.target.value)
80
+ }
81
+
37
82
  function validateForm(values) {
38
83
  const next = {}
39
84
  let valid = true
@@ -52,18 +97,24 @@ async function onSubmit(event) {
52
97
  event.preventDefault()
53
98
  submitCount.set(submitCount() + 1)
54
99
 
100
+ // initialValues supplies anything the form does not post (an untouched field
101
+ // with no input, a value held outside the DOM); FormData still wins where both
102
+ // have the key, so what the user actually typed is never overridden.
55
103
  const formData = new FormData(event.target)
56
- const values = {}
57
- for (const [k, v] of formData.entries()) values[k] = v
104
+ const submitted = { ...initialValues }
105
+ for (const [k, v] of formData.entries()) submitted[k] = v
106
+ values.set(submitted)
58
107
 
59
- if (!validateForm(values)) {
108
+ if (!validateForm(submitted)) {
60
109
  emit('error', errors())
61
110
  return
62
111
  }
63
112
 
64
113
  isSubmitting.set(true)
65
114
  try {
66
- emit('submit', values)
115
+ // Second argument is FormHelpers, which the published type has always
116
+ // promised and which nothing used to pass.
117
+ emit('submit', submitted, helpers)
67
118
  }
68
119
  finally {
69
120
  isSubmitting.set(false)
@@ -74,14 +125,29 @@ function setErrors(next) {
74
125
  errors.set({ ...errors(), ...next })
75
126
  }
76
127
 
128
+ function setValues(next) {
129
+ values.set({ ...values(), ...next })
130
+ }
131
+
77
132
  function reset(form) {
78
133
  errors.set({})
79
134
  isSubmitting.set(false)
80
135
  submitCount.set(0)
136
+ values.set({ ...initialValues })
81
137
  if (form?.reset) form.reset()
82
138
  }
83
139
 
84
- defineExpose({ errors, isSubmitting, submitCount, setErrors, reset, validateForm })
140
+ // FormHelpers named setValues/resetForm; the component exposed setErrors/reset
141
+ // and no setValues at all, so the documented shape could not be destructured.
142
+ // Both spellings are exposed: resetForm/setValues to satisfy the type, reset to
143
+ // keep the existing call sites working.
144
+ function resetForm(form) {
145
+ reset(form)
146
+ }
147
+
148
+ const helpers = { setErrors, setValues, resetForm }
149
+
150
+ defineExpose({ errors, values, isSubmitting, submitCount, setErrors, setValues, reset, resetForm, validateForm, validateField })
85
151
  </script>
86
152
 
87
153
  <form
@@ -89,6 +155,8 @@ defineExpose({ errors, isSubmitting, submitCount, setErrors, reset, validateForm
89
155
  @if(action)action="{{ action }}"@endif
90
156
  method="{{ method }}"
91
157
  @submit="onSubmit($event)"
158
+ @input="onFieldInput($event)"
159
+ @focusout="onFieldBlur($event)"
92
160
  novalidate
93
161
  >
94
162
  <slot />
@@ -19,20 +19,37 @@ export interface ValidationSchema {
19
19
  [fieldName: string]: FieldSchema
20
20
  }
21
21
 
22
+ /**
23
+ * Passed as the second argument of the `submit` event.
24
+ *
25
+ * These names are the ones the component actually exposes. Before #1874 the type
26
+ * named `setValues` and `resetForm` while the component implemented `setErrors`
27
+ * and `reset` and had no `setValues` at all, so destructuring this shape gave you
28
+ * two undefined functions.
29
+ */
22
30
  export interface FormHelpers {
23
31
  setErrors: (errors: Record<string, string>) => void
24
32
  setValues: (values: Record<string, any>) => void
25
- resetForm: () => void
33
+ resetForm: (form?: HTMLFormElement) => void
26
34
  }
27
35
 
28
36
  export interface FormProps {
29
37
  action?: string
30
38
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
31
39
  validationSchema?: ValidationSchema
40
+ /** Seeds the form's values, fills gaps in the submitted payload, and is what resetForm() restores to. */
32
41
  initialValues?: Record<string, any>
42
+ /** Validate a field as it is typed into. Delegated from the form's `input` event. */
33
43
  validateOnChange?: boolean
44
+ /** Validate a field when it loses focus. Delegated from the form's `focusout` event. */
34
45
  validateOnBlur?: boolean
46
+ /**
47
+ * Bound as the `submit` EVENT, not as a callback prop: `<Form @submit="…">`.
48
+ * Receives (values, helpers). stx components communicate upward through
49
+ * defineEmits, so there is no prop-callback channel to pass a function down.
50
+ */
35
51
  onSubmit?: (values: Record<string, any>, helpers: FormHelpers) => void | Promise<void>
52
+ /** Bound as the `error` EVENT: `<Form @error="…">`. Receives the error map. */
36
53
  onError?: (errors: Record<string, string> | Error) => void
37
54
  className?: string
38
55
  }
@@ -64,6 +64,7 @@ function onAction(event) {
64
64
  data-space
65
65
  data-space-id="{{ id }}"
66
66
  data-space-active="{{ active ? 'true' : 'false' }}"
67
+ @if(!active)inert@endif
67
68
  class="flex min-w-0 flex-1 flex-col"
68
69
  role="tabpanel"
69
70
  aria-label="{{ label || id }}"
@@ -51,6 +51,19 @@ export const config = JSON.stringify({
51
51
  </script>
52
52
 
53
53
  <script client>
54
+ import {
55
+ clampIndex,
56
+ nativeVelocityToPanels,
57
+ panelStep,
58
+ pointerAxis,
59
+ settleTarget,
60
+ SETTLE_EASE,
61
+ SETTLE_MS,
62
+ wheelClaims,
63
+ WHEEL_IDLE_MS,
64
+ withResistance,
65
+ } from './swipe'
66
+
54
67
  const emit = defineEmits()
55
68
  const componentRoot = window.__STX_CURRENT_ELEMENT__
56
69
 
@@ -72,39 +85,9 @@ let tints = []
72
85
  // 220px pane and a 320px pane need the same flick to advance.
73
86
  // ---------------------------------------------------------------------------
74
87
 
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
88
  function paint(offset, animate) {
91
89
  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))
90
+ track.style.transform = `translate3d(${-offset * panelStep(ids.length)}%, 0, 0)`
108
91
  }
109
92
 
110
93
  function animates() {
@@ -174,7 +157,7 @@ function restored() {
174
157
  /** Move to a space. Always repaints — a rejected swipe still animates home. */
175
158
  function goTo(next, options) {
176
159
  const opts = options || {}
177
- const target = clampIndex(next)
160
+ const target = clampIndex(next, ids.length)
178
161
  const changed = target !== index()
179
162
 
180
163
  paint(target, opts.animate !== false && animates())
@@ -192,8 +175,9 @@ function goTo(next, options) {
192
175
  // Gestures
193
176
  //
194
177
  // 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`.
178
+ // helpers: they track live with `paint(withResistance(…), false)` while moving,
179
+ // then hand the final offset and velocity to `settle`. The thresholds and the
180
+ // resistance curve live in `./swipe` so they can be tested directly.
197
181
  // ---------------------------------------------------------------------------
198
182
 
199
183
  let gestureStart = 0
@@ -204,12 +188,7 @@ function beginGesture() {
204
188
  }
205
189
 
206
190
  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 })
191
+ goTo(settleTarget(offset, gestureStart, velocity, ids.length), { source })
213
192
  }
214
193
 
215
194
  // --- Trackpad ---------------------------------------------------------------
@@ -222,13 +201,18 @@ let wheelOffset = null
222
201
  let wheelIdle = null
223
202
  let wheelVelocity = 0
224
203
  let wheelLastAt = 0
204
+ // Set the first time the host delivers a real gesture phase. From then on the
205
+ // wheel stream is the same physical events arriving a second time, so running
206
+ // the heuristic as well would drive the track twice and settle it twice.
207
+ let nativeGesturesConfirmed = false
225
208
 
226
209
  function onWheel(event) {
210
+ if (nativeGesturesConfirmed) return
227
211
  if (ids.length < 2) return
228
212
  if (event.ctrlKey) return // pinch-zoom, not a swipe
229
213
 
230
214
  if (wheelOffset === null) {
231
- if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return
215
+ if (!wheelClaims(event.deltaX, event.deltaY)) return
232
216
  beginGesture()
233
217
  wheelOffset = gestureStart
234
218
  wheelLastAt = event.timeStamp
@@ -240,10 +224,10 @@ function onWheel(event) {
240
224
  wheelLastAt = event.timeStamp
241
225
  wheelVelocity = delta / elapsed
242
226
  wheelOffset += delta
243
- paint(withResistance(wheelOffset), false)
227
+ paint(withResistance(wheelOffset, ids.length), false)
244
228
 
245
229
  clearTimeout(wheelIdle)
246
- wheelIdle = setTimeout(endWheel, 90)
230
+ wheelIdle = setTimeout(endWheel, WHEEL_IDLE_MS)
247
231
  }
248
232
 
249
233
  function endWheel() {
@@ -253,10 +237,17 @@ function endWheel() {
253
237
  settle(offset, wheelVelocity, 'swipe')
254
238
  }
255
239
 
256
- /** Drop an in-flight wheel gesture without settling it. */
240
+ /**
241
+ * Drop an in-flight wheel gesture without settling it, returning wherever it
242
+ * had dragged the track to. The caller adopts that offset so the handover to
243
+ * the native stream is invisible — resetting to the logical index instead
244
+ * would snap the track backwards at the exact moment the gesture gets better.
245
+ */
257
246
  function cancelWheelGesture() {
258
247
  clearTimeout(wheelIdle)
248
+ const carried = wheelOffset
259
249
  wheelOffset = null
250
+ return carried
260
251
  }
261
252
 
262
253
  // --- Touch and pen ----------------------------------------------------------
@@ -290,10 +281,9 @@ function onPointerMove(event) {
290
281
  // Axis lock. The first 8px of travel decides swipe or scroll, and the
291
282
  // decision stands for the rest of the gesture — re-deciding mid-drag is
292
283
  // 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 }
284
+ const axis = pointerAxis(event.clientX - pointerStartX, event.clientY - pointerStartY)
285
+ if (axis === 'undecided') return
286
+ if (axis === 'vertical') { pointerId = null; return }
297
287
  beginGesture()
298
288
  pointerOffset = gestureStart
299
289
  viewport.setPointerCapture(event.pointerId)
@@ -305,7 +295,7 @@ function onPointerMove(event) {
305
295
  pointerLastAt = event.timeStamp
306
296
  pointerVelocity = delta / elapsed
307
297
  pointerOffset += delta
308
- paint(withResistance(pointerOffset), false)
298
+ paint(withResistance(pointerOffset, ids.length), false)
309
299
  }
310
300
 
311
301
  function onPointerUp(event) {
@@ -347,10 +337,24 @@ function onShortcut(event) {
347
337
  if (!event.metaKey || !event.altKey) return
348
338
  const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
349
339
  if (step === 0) return
340
+ // Window-level, so a page with two spaces sidebars would otherwise advance
341
+ // both at once. Whichever one contains the focus owns the shortcut; with
342
+ // focus outside every sidebar the sole sidebar still answers, because that
343
+ // is the common case and requiring a click first would be surprising.
344
+ if (!ownsShortcut()) return
350
345
  event.preventDefault()
351
346
  goTo(index() + step, { source: 'keyboard' })
352
347
  }
353
348
 
349
+ function ownsShortcut() {
350
+ const focused = document.activeElement
351
+ const owner = focused && focused !== document.body
352
+ ? focused.closest('[data-stx-sidebar-spaces]')
353
+ : null
354
+ if (owner) return owner === root
355
+ return document.querySelectorAll('[data-stx-sidebar-spaces]').length === 1
356
+ }
357
+
354
358
  // ---------------------------------------------------------------------------
355
359
  // Craft
356
360
  //
@@ -381,11 +385,15 @@ function bindCraftGestures() {
381
385
  // Take over from the wheel path. The bridge exposes `onSwipe`
382
386
  // unconditionally but a host that does not implement gestures never
383
387
  // 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()
388
+ // we know the native stream is real. From here the heuristic is not just
389
+ // redundant but harmful — the same physical scroll arrives on both paths
390
+ // — so it is retired for good, not merely cancelled.
391
+ nativeGesturesConfirmed = true
392
+ const carried = cancelWheelGesture()
387
393
  beginGesture()
388
- offset = gestureStart
394
+ // Adopt whatever the wheel path had already dragged to, so the handover
395
+ // does not snap the track back to the logical index.
396
+ offset = carried === null ? gestureStart : carried
389
397
  return
390
398
  }
391
399
  if (offset === null) return
@@ -395,12 +403,12 @@ function bindCraftGestures() {
395
403
  offset = null
396
404
  // Craft reports velocity in points per second; the thresholds here are
397
405
  // panels per millisecond.
398
- settle(settled, (swipe.velocityX || 0) / (viewport.clientWidth || 1) / 1000, 'swipe')
406
+ settle(settled, nativeVelocityToPanels(swipe.velocityX || 0, viewport.clientWidth), 'swipe')
399
407
  return
400
408
  }
401
409
 
402
410
  offset += (swipe.deltaX || 0) / (viewport.clientWidth || 1)
403
- paint(withResistance(offset), false)
411
+ paint(withResistance(offset, ids.length), false)
404
412
  })
405
413
 
406
414
  if (typeof off === 'function') craftTeardown.push(off)
@@ -444,7 +452,7 @@ onMount(() => {
444
452
  tints = spaces.map(space => space.tint)
445
453
  if (ids.length === 0) return
446
454
 
447
- const startIndex = clampIndex(config.index || 0)
455
+ const startIndex = clampIndex(config.index || 0, ids.length)
448
456
  const saved = restored()
449
457
  const initial = saved === -1 ? startIndex : saved
450
458
  index.set(initial)
@@ -523,6 +531,57 @@ defineExpose({
523
531
  </div>
524
532
 
525
533
  <style scoped>
534
+ /* ---------------------------------------------------------------------------
535
+ * Standalone tinting.
536
+ *
537
+ * `applyTint` writes the palette onto `root.closest('[data-stx-sidebar]')` and
538
+ * falls back to this component's own root — the documented way to use
539
+ * `<SidebarSpaces>` without a `<Sidebar>` around it. But the rules that
540
+ * *consume* those variables live in Sidebar.stx, so the fallback target used to
541
+ * publish a palette nothing read: standalone spaces rendered with no gradient
542
+ * and no crossfade at all.
543
+ *
544
+ * These mirror Sidebar.stx's spaces block for the standalone case only. The
545
+ * `:not([data-stx-sidebar] *)` guard keeps them out of the nested case, where
546
+ * the pane is the thing that should be painted, not this inner column.
547
+ * ------------------------------------------------------------------------- */
548
+ @property --stx-space-from { syntax: "<color>"; inherits: true; initial-value: #f2f0ec; }
549
+ @property --stx-space-to { syntax: "<color>"; inherits: true; initial-value: #e6e3dd; }
550
+ @property --stx-space-ink { syntax: "<color>"; inherits: true; initial-value: #2c2a28; }
551
+ @property --stx-space-accent { syntax: "<color>"; inherits: true; initial-value: #5b5b63; }
552
+
553
+ [data-stx-sidebar-spaces]:not([data-stx-sidebar] *) {
554
+ --stx-space-from: var(--stx-space-light-from, #f2f0ec);
555
+ --stx-space-to: var(--stx-space-light-to, #e6e3dd);
556
+ --stx-space-ink: var(--stx-space-light-ink, #2c2a28);
557
+ --stx-space-accent: var(--stx-space-light-accent, #5b5b63);
558
+ background:
559
+ linear-gradient(180deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 32%),
560
+ linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
561
+ color: var(--stx-space-ink);
562
+ transition:
563
+ --stx-space-from 420ms ease,
564
+ --stx-space-to 420ms ease,
565
+ --stx-space-ink 420ms ease,
566
+ --stx-space-accent 420ms ease;
567
+ }
568
+
569
+ @media (prefers-color-scheme: dark) {
570
+ [data-stx-sidebar-spaces]:not([data-stx-sidebar] *) {
571
+ --stx-space-from: var(--stx-space-dark-from, #17171b);
572
+ --stx-space-to: var(--stx-space-dark-to, #0e0e11);
573
+ --stx-space-ink: var(--stx-space-dark-ink, #ededf0);
574
+ --stx-space-accent: var(--stx-space-dark-accent, #b9b9c2);
575
+ background:
576
+ linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 30%),
577
+ linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
578
+ }
579
+ }
580
+
581
+ @media (prefers-reduced-motion: reduce) {
582
+ [data-stx-sidebar-spaces]:not([data-stx-sidebar] *) { transition: none; }
583
+ }
584
+
526
585
  .stx-space-viewport {
527
586
  /* Vertical scrolling stays with the browser; horizontal belongs to us. */
528
587
  touch-action: pan-y;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The geometry and commit rules behind a spaces swipe.
3
+ *
4
+ * Extracted from `SidebarSpaces.stx` for the same reason `route-selection.ts`
5
+ * is: the component's client script cannot be imported, and this is the part
6
+ * worth pinning down. Everything here is pure — no DOM, no state — so the
7
+ * thresholds that decide whether a swipe advances can be asserted directly
8
+ * instead of inferred from a rendered pane.
9
+ *
10
+ * Position is measured in *panels*, not pixels: 1.5 means halfway between the
11
+ * second and third space. Keeping the gesture in panel units makes every
12
+ * threshold independent of how wide the sidebar happens to be, so a 220px pane
13
+ * and a 320px pane need the same flick to advance.
14
+ */
15
+
16
+ /** Settle animation, matched by the tint crossfade so colour and motion agree. */
17
+ export const SETTLE_MS = 420
18
+ export const SETTLE_EASE = 'cubic-bezier(0.22, 0.61, 0.36, 1)'
19
+
20
+ /**
21
+ * Travel past the first/last space is compressed towards this asymptote, the
22
+ * same thing a UIScrollView does at the end of a list: the track keeps
23
+ * answering the finger, but tells you there is nothing further.
24
+ */
25
+ export const RUBBER_LIMIT = 0.28
26
+
27
+ /** Drag this fraction of a panel and release, and the swipe commits. */
28
+ export const COMMIT_DISTANCE = 0.3
29
+
30
+ /** Or flick: panels per millisecond, i.e. 1.5 panels/second. */
31
+ export const COMMIT_VELOCITY = 0.0015
32
+
33
+ /** The first pointer travel that decides swipe-or-scroll, in CSS pixels. */
34
+ export const AXIS_LOCK_PX = 8
35
+
36
+ /** A wheel gesture ends when the stream goes quiet for this long. */
37
+ export const WHEEL_IDLE_MS = 90
38
+
39
+ /** Width of one panel as a percentage of the whole track. */
40
+ export function panelStep(count: number): number {
41
+ return 100 / Math.max(1, count)
42
+ }
43
+
44
+ /** The compression curve applied beyond either end of the track. */
45
+ export function rubber(distance: number): number {
46
+ return (1 - 1 / (distance / RUBBER_LIMIT + 1)) * RUBBER_LIMIT
47
+ }
48
+
49
+ /** Clamp an offset to the track, compressing rather than stopping at the ends. */
50
+ export function withResistance(offset: number, count: number): number {
51
+ const last = count - 1
52
+ if (offset < 0)
53
+ return -rubber(-offset)
54
+ if (offset > last)
55
+ return last + rubber(offset - last)
56
+ return offset
57
+ }
58
+
59
+ export function clampIndex(value: number, count: number): number {
60
+ return Math.max(0, Math.min(count - 1, value))
61
+ }
62
+
63
+ /**
64
+ * Where a released gesture lands.
65
+ *
66
+ * A swipe advances at most one space however far or fast it was dragged —
67
+ * Arc behaves the same way, and it keeps a fast flick from skipping past the
68
+ * space you were looking at.
69
+ */
70
+ export function settleTarget(
71
+ offset: number,
72
+ gestureStart: number,
73
+ velocity: number,
74
+ count: number,
75
+ ): number {
76
+ const moved = offset - gestureStart
77
+ const direction = Math.sign(moved) || Math.sign(velocity)
78
+ const dragged = Math.abs(moved) > COMMIT_DISTANCE
79
+ const flicked = Math.abs(velocity) > COMMIT_VELOCITY && Math.sign(velocity) === direction
80
+ const commit = direction !== 0 && (dragged || flicked)
81
+ return clampIndex(commit ? gestureStart + direction : gestureStart, count)
82
+ }
83
+
84
+ /**
85
+ * Whether a wheel event should start a swipe.
86
+ *
87
+ * Claimed only when the very first event is unambiguously horizontal. A DOM
88
+ * wheel stream carries no phase information, so this is a guess — the native
89
+ * path in Craft replaces it with real `NSEvent` phases when the host provides
90
+ * them.
91
+ */
92
+ export function wheelClaims(deltaX: number, deltaY: number): boolean {
93
+ return Math.abs(deltaX) > Math.abs(deltaY)
94
+ }
95
+
96
+ export type PointerAxis = 'undecided' | 'horizontal' | 'vertical'
97
+
98
+ /**
99
+ * Axis lock for touch and pen. The first 8px of travel decides, and the
100
+ * decision stands for the rest of the gesture — re-deciding mid-drag is what
101
+ * makes web carousels feel slippery.
102
+ */
103
+ export function pointerAxis(dx: number, dy: number): PointerAxis {
104
+ if (Math.abs(dx) < AXIS_LOCK_PX && Math.abs(dy) < AXIS_LOCK_PX)
105
+ return 'undecided'
106
+ return Math.abs(dx) > Math.abs(dy) ? 'horizontal' : 'vertical'
107
+ }
108
+
109
+ /**
110
+ * Convert Craft's release velocity (points per second) into the panels per
111
+ * millisecond the commit thresholds are expressed in.
112
+ */
113
+ export function nativeVelocityToPanels(velocityX: number, viewportWidth: number): number {
114
+ return velocityX / (viewportWidth || 1) / 1000
115
+ }
@@ -1,95 +0,0 @@
1
- <script server>
2
- export const action = $props.action || ''
3
- export const method = $props.method || 'POST'
4
- export const validationSchema = $props.validationSchema || {}
5
- export const className = $props.className || ''
6
-
7
- export const formClasses = `space-y-6 ${className}`.trim()
8
- export const errorClasses = `mt-1 text-sm text-red-600 dark:text-red-400`.trim()
9
- </script>
10
-
11
- <script client>
12
- const emit = defineEmits()
13
- const validationSchema = {{ validationSchema }}
14
-
15
- const errors = state({})
16
- const isSubmitting = state(false)
17
- const submitCount = state(0)
18
-
19
- function validateField(name, value, allValues) {
20
- const schema = validationSchema[name]
21
- if (!schema) return null
22
- if (schema.required && !value) {
23
- return schema.requiredMessage || `${name} is required`
24
- }
25
- if (schema.minLength && String(value).length < schema.minLength) {
26
- return schema.minLengthMessage || `${name} must be at least ${schema.minLength} characters`
27
- }
28
- if (schema.maxLength && String(value).length > schema.maxLength) {
29
- return schema.maxLengthMessage || `${name} must be at most ${schema.maxLength} characters`
30
- }
31
- if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
32
- return schema.patternMessage || `${name} is invalid`
33
- }
34
- return null
35
- }
36
-
37
- function validateForm(values) {
38
- const next = {}
39
- let valid = true
40
- for (const name of Object.keys(validationSchema)) {
41
- const err = validateField(name, values[name], values)
42
- if (err) {
43
- next[name] = err
44
- valid = false
45
- }
46
- }
47
- errors.set(next)
48
- return valid
49
- }
50
-
51
- async function onSubmit(event) {
52
- event.preventDefault()
53
- submitCount.set(submitCount() + 1)
54
-
55
- const formData = new FormData(event.target)
56
- const values = {}
57
- for (const [k, v] of formData.entries()) values[k] = v
58
-
59
- if (!validateForm(values)) {
60
- emit('error', errors())
61
- return
62
- }
63
-
64
- isSubmitting.set(true)
65
- try {
66
- emit('submit', values)
67
- }
68
- finally {
69
- isSubmitting.set(false)
70
- }
71
- }
72
-
73
- function setErrors(next) {
74
- errors.set({ ...errors(), ...next })
75
- }
76
-
77
- function reset(form) {
78
- errors.set({})
79
- isSubmitting.set(false)
80
- submitCount.set(0)
81
- if (form?.reset) form.reset()
82
- }
83
-
84
- defineExpose({ errors, isSubmitting, submitCount, setErrors, reset, validateForm })
85
- </script>
86
-
87
- <form
88
- class="{{ formClasses }}"
89
- @if(action)action="{{ action }}"@endif
90
- method="{{ method }}"
91
- @submit="onSubmit($event)"
92
- novalidate
93
- >
94
- <slot />
95
- </form>