@stacksjs/components 0.2.155 → 0.2.157
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/Form-bpnpbymb.stx +163 -0
- package/dist/{SidebarSpace-61b662jx.stx → SidebarSpace-h50n47va.stx} +1 -0
- package/dist/{SidebarSpaces-gq3mx984.stx → SidebarSpaces-sy8dy0gb.stx} +115 -56
- package/dist/index.js +7 -7
- package/dist/ui/form/index.d.ts +9 -1
- package/dist/ui/sidebar/swipe.d.ts +67 -0
- package/package.json +3 -3
- package/src/ui/form/Form.stx +73 -5
- package/src/ui/form/index.ts +18 -1
- package/src/ui/sidebar/SidebarSpace.stx +1 -0
- package/src/ui/sidebar/SidebarSpaces.stx +115 -56
- package/src/ui/sidebar/swipe.ts +115 -0
- package/dist/Form-mas630hy.stx +0 -95
|
@@ -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>
|
|
@@ -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(
|
|
196
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
/**
|
|
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
|
|
294
|
-
|
|
295
|
-
if (
|
|
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.
|
|
385
|
-
//
|
|
386
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
2
|
+
// ../../../../../../../tmp/stx-components-build-3eMTad/stubs/stub-0.js
|
|
3
3
|
var stub_0_default = { __stx: true, src: "./components/CodeBlock.stx" };
|
|
4
|
-
// ../../../../../../../tmp/stx-components-build-
|
|
4
|
+
// ../../../../../../../tmp/stx-components-build-3eMTad/stubs/stub-1.js
|
|
5
5
|
var stub_1_default = { __stx: true, src: "./components/Footer.stx" };
|
|
6
|
-
// ../../../../../../../tmp/stx-components-build-
|
|
6
|
+
// ../../../../../../../tmp/stx-components-build-3eMTad/stubs/stub-2.js
|
|
7
7
|
var stub_2_default = { __stx: true, src: "./components/Hero.stx" };
|
|
8
|
-
// ../../../../../../../tmp/stx-components-build-
|
|
8
|
+
// ../../../../../../../tmp/stx-components-build-3eMTad/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-
|
|
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-
|
|
1147
|
+
var SidebarSpace_default = "./SidebarSpace-h50n47va.stx";
|
|
1148
1148
|
// src/ui/sidebar/SidebarSpaces.stx
|
|
1149
|
-
var SidebarSpaces_default = "./SidebarSpaces-
|
|
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
|
package/dist/ui/form/index.d.ts
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.2.157",
|
|
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.
|
|
68
|
-
"bun-plugin-stx": "0.2.
|
|
67
|
+
"@stacksjs/stx": "0.2.157",
|
|
68
|
+
"bun-plugin-stx": "0.2.157",
|
|
69
69
|
"ts-syntax-highlighter": "^0.2.1"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
package/src/ui/form/Form.stx
CHANGED
|
@@ -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
|
|
57
|
-
for (const [k, v] of formData.entries())
|
|
104
|
+
const submitted = { ...initialValues }
|
|
105
|
+
for (const [k, v] of formData.entries()) submitted[k] = v
|
|
106
|
+
values.set(submitted)
|
|
58
107
|
|
|
59
|
-
if (!validateForm(
|
|
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
|
-
|
|
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
|
-
|
|
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 />
|
package/src/ui/form/index.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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(
|
|
196
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
/**
|
|
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
|
|
294
|
-
|
|
295
|
-
if (
|
|
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.
|
|
385
|
-
//
|
|
386
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/Form-mas630hy.stx
DELETED
|
@@ -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>
|