@bagelink/vue 1.15.482 → 1.15.490

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.15.482",
4
+ "version": "1.15.490",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -77,6 +77,15 @@ const btnLayouts: NumberLayout[] = ['horizontal', 'vertical']
77
77
 
78
78
  const TRAILING_ZEROS_RE = /\.\d*0$/
79
79
 
80
+ /** Mid-keystroke drafts that are not yet a finished number (e.g. "1.", "1.50", "-"). */
81
+ function isIncompleteDraft(value: string) {
82
+ if (!value) return false
83
+ const numeric = value.replace(/[^\d.-]/g, '')
84
+ return ['-', '.', '-.'].includes(numeric)
85
+ || numeric.endsWith('.')
86
+ || TRAILING_ZEROS_RE.test(numeric)
87
+ }
88
+
80
89
  function getPrecision() {
81
90
  return step.toString().split('.')[1]?.length || 0
82
91
  }
@@ -98,12 +107,14 @@ const canDecrement = computed(() => min === undefined || subtract(numberValue.va
98
107
  function increment() {
99
108
  if (!canIncrement.value) { return }
100
109
  numberValue.value = add(numberValue.value ?? 0, step)
110
+ formattedValue.value = formatNumber(numberValue.value)
101
111
  emit('update:modelValue', numberValue.value)
102
112
  }
103
113
 
104
114
  function decrement() {
105
115
  if (!canDecrement.value) { return }
106
116
  numberValue.value = subtract(numberValue.value ?? 0, step)
117
+ formattedValue.value = formatNumber(numberValue.value)
107
118
  emit('update:modelValue', numberValue.value)
108
119
  }
109
120
 
@@ -128,10 +139,18 @@ function formatNumber(num: number) {
128
139
  const formattedValue = ref('')
129
140
  function inputHandler() {
130
141
  const numeric = formattedValue.value.replace(/[^\d.-]/g, '')
131
- const isIncomplete = ['', '-', '.', '-.'].includes(numeric) || numeric.endsWith('.') || TRAILING_ZEROS_RE.test(numeric)
142
+ const isIncomplete = numeric === ''
143
+ || ['-', '.', '-.'].includes(numeric)
144
+ || numeric.endsWith('.')
145
+ || TRAILING_ZEROS_RE.test(numeric)
132
146
 
133
147
  if (isIncomplete) {
134
- emit('update:modelValue', numeric === '-' ? '-' : numeric)
148
+ if (numeric === '') {
149
+ numberValue.value = undefined
150
+ emit('update:modelValue', undefined)
151
+ }
152
+ // Keep the local draft ("1.", "0.10", …). Do not emit strings —
153
+ // number-only v-models coerce/clear them and wipe the decimal point.
135
154
  return
136
155
  }
137
156
 
@@ -142,18 +161,43 @@ function inputHandler() {
142
161
 
143
162
  watch(() => numberValue.value, () => {
144
163
  nextTick(() => {
145
- if (formattedValue.value.endsWith('.') || TRAILING_ZEROS_RE.test(formattedValue.value)) { return }
164
+ if (isIncompleteDraft(formattedValue.value)) { return }
146
165
  formattedValue.value = numberValue.value !== undefined ? formatNumber(numberValue.value) : ''
147
166
  })
148
167
  })
149
168
 
150
169
  watch(() => modelValue, (newVal) => {
170
+ // Parent re-rendered while the user is mid-decimal — keep the draft unless
171
+ // the parent intentionally replaced/cleared the value.
172
+ if (isIncompleteDraft(formattedValue.value)) {
173
+ if (typeof newVal === 'string' && isIncompleteDraft(newVal)) {
174
+ formattedValue.value = newVal
175
+ return
176
+ }
177
+ if (typeof newVal === 'number') {
178
+ const draftParsed = Number.parseFloat(formattedValue.value)
179
+ // Coerced echo of the same draft (e.g. "1." → 1) — keep typing.
180
+ if (!Number.isNaN(draftParsed) && draftParsed === newVal) return
181
+ numberValue.value = newVal
182
+ formattedValue.value = formatNumber(newVal)
183
+ return
184
+ }
185
+ if (newVal === undefined || newVal === null || newVal === '') {
186
+ // Empty parent while we never committed a number = normal mid-draft.
187
+ if (numberValue.value === undefined) return
188
+ // Parent cleared a previously committed value (e.g. external reset).
189
+ numberValue.value = undefined
190
+ formattedValue.value = ''
191
+ return
192
+ }
193
+ return
194
+ }
151
195
  if (newVal === undefined || newVal === null || newVal === '') {
152
196
  numberValue.value = undefined
153
197
  formattedValue.value = ''
154
198
  return
155
199
  }
156
- if (typeof newVal === 'string' && (newVal === '-' || newVal.endsWith('.'))) {
200
+ if (typeof newVal === 'string' && isIncompleteDraft(newVal)) {
157
201
  formattedValue.value = newVal
158
202
  return
159
203
  }
@@ -400,6 +400,7 @@ function getFieldProps(field: FieldBuilder, key: string) {
400
400
  'onUpdate:modelValue': (val: any) => { updateField(key, val, field) },
401
401
  'error': mergedErrors.value[key],
402
402
  ...configRest,
403
+ 'formData': formData.value,
403
404
  // Inputs use `helptext`; FormFlow schema API keeps `helpText`
404
405
  ...(helpText != null ? { helptext: helpText } : {}),
405
406
  name: field._config.name ?? key,
@@ -23,6 +23,9 @@ import { normalizeCustomFormSubmission } from './custom-field-registry'
23
23
  import { fromJSONSchema } from './form-flow'
24
24
  import type { JSONSchemaOptionsSourceLoader } from './form-flow'
25
25
  import Btn from '../components/Btn.vue'
26
+ import Icon from '../components/Icon/Icon.vue'
27
+ import ListItem from '../components/ListItem.vue'
28
+ import Progress from '../components/Progress.vue'
26
29
  import FormFlow from './FormFlow.vue'
27
30
  import type { FormStepCompleteEventPayload, FormStepEventPayload } from './structure'
28
31
  import { autoAdvanceFieldKey, entryStepKey, pathProgress, resolveNext, stepAutoAdvances, stepByKey } from './structure'
@@ -31,6 +34,8 @@ export interface Props {
31
34
  structure: FormStructure
32
35
  modelValue?: Record<string, any>
33
36
  components?: Record<string, any>
37
+ /** Stable step key to resume. Falls back to the structure entry step. */
38
+ initialStepKey?: string | null
34
39
  /** Human name of the form, included in step/complete event payloads. */
35
40
  formName?: string
36
41
  /** Slide between steps (honors prefers-reduced-motion). Default true. */
@@ -52,6 +57,8 @@ export interface Props {
52
57
  hideTitle?: boolean
53
58
  /** Hide the step-position counter pill in the default nav. Default false. */
54
59
  hideCounter?: boolean
60
+ /** Show numbered stages with completed-step navigation. Default false. */
61
+ showStages?: boolean
55
62
  /** Optional async submit handler; awaited before `complete` is emitted. */
56
63
  onComplete?: (data: Record<string, any>) => Promise<void> | void
57
64
  /**
@@ -75,6 +82,7 @@ const props = withDefaults(defineProps<Props>(), {
75
82
  redirect: true,
76
83
  hideTitle: false,
77
84
  hideCounter: false,
85
+ showStages: false,
78
86
  })
79
87
 
80
88
  const emit = defineEmits<{
@@ -116,14 +124,30 @@ watch(() => props.modelValue, (val) => {
116
124
 
117
125
  // Navigation state: the current step key plus the stack of previously visited
118
126
  // keys (for Back). Initialised to the structure's entry step.
119
- const currentKey = ref<string | undefined>(entryStepKey(props.structure))
127
+ function initialKey(): string | undefined {
128
+ const requested = props.initialStepKey
129
+ return requested && stepByKey(props.structure, requested)
130
+ ? requested
131
+ : entryStepKey(props.structure)
132
+ }
133
+
134
+ const currentKey = ref<string | undefined>(initialKey())
120
135
  const history = ref<string[]>([])
136
+ const completedKeys = ref<Set<string>>(new Set())
121
137
 
122
138
  watch(() => props.structure, () => {
123
- currentKey.value = entryStepKey(props.structure)
139
+ currentKey.value = initialKey()
124
140
  history.value = []
141
+ completedKeys.value = new Set()
125
142
  }, { deep: true })
126
143
 
144
+ watch(() => props.initialStepKey, (key) => {
145
+ if (!key || key === currentKey.value || !stepByKey(props.structure, key)) return
146
+ currentKey.value = key
147
+ history.value = []
148
+ completedKeys.value = new Set()
149
+ })
150
+
127
151
  watch(currentKey, (key) => {
128
152
  if (key) emit('stepChange', key)
129
153
  }, { immediate: true })
@@ -188,6 +212,32 @@ const canGoBack = computed(() => history.value.length > 0)
188
212
  // host can render its own <Progress> (the player stays chrome-light).
189
213
  const progress = computed(() => pathProgress(props.structure, currentKey.value, formData.value))
190
214
  watch(progress, val => emit('progress', val), { immediate: true })
215
+ const pathSteps = computed(() => {
216
+ const result: FormStep[] = []
217
+ const seen = new Set<string>()
218
+ let key = entryStepKey(props.structure)
219
+ while (key && !seen.has(key)) {
220
+ seen.add(key)
221
+ const step = stepByKey(props.structure, key)
222
+ if (!step) break
223
+ result.push(step)
224
+ key = resolveNext(props.structure, key, formData.value) ?? undefined
225
+ }
226
+ return result
227
+ })
228
+ const currentPathIndex = computed(() =>
229
+ pathSteps.value.findIndex(step => step.key === currentKey.value)
230
+ )
231
+ watch([pathSteps, currentPathIndex], ([steps, index]) => {
232
+ if (index <= 0) return
233
+ if (history.value.length === 0) {
234
+ history.value = steps.slice(0, index).map(step => step.key)
235
+ }
236
+ completedKeys.value = new Set([
237
+ ...completedKeys.value,
238
+ ...steps.slice(0, index).map(step => step.key),
239
+ ])
240
+ }, { immediate: true })
191
241
 
192
242
  const formRef = ref<{ validate: () => boolean } | null>(null)
193
243
  const submitting = ref(false)
@@ -243,6 +293,7 @@ async function onPrimary() {
243
293
  // Advance to the resolved next step.
244
294
  const target = nextKey.value
245
295
  if (target && currentKey.value) {
296
+ completedKeys.value = new Set([...completedKeys.value, currentKey.value])
246
297
  const destination = stepByKey(props.structure, target)
247
298
  emit('stepComplete', {
248
299
  ...buildPayload(currentKey.value),
@@ -310,15 +361,68 @@ function onBack() {
310
361
  }
311
362
  }
312
363
 
364
+ function canOpenStage(key: string): boolean {
365
+ if (key === currentKey.value || completedKeys.value.has(key)) return true
366
+ return key === nextKey.value
367
+ }
368
+
369
+ async function goToStage(key: string) {
370
+ if (key === currentKey.value || !canOpenStage(key)) return
371
+ const targetIndex = pathSteps.value.findIndex(step => step.key === key)
372
+ const currentIndex = currentPathIndex.value
373
+ if (targetIndex < 0 || currentIndex < 0) return
374
+ if (key === nextKey.value) {
375
+ await onPrimary()
376
+ return
377
+ }
378
+ slideDir.value = targetIndex < currentIndex ? 'prev' : 'next'
379
+ history.value = pathSteps.value.slice(0, targetIndex).map(step => step.key)
380
+ currentKey.value = key
381
+ }
382
+
313
383
  // Progress: position in the visited path (best-effort; branches make total
314
384
  // step count indeterminate, so we show visited-count rather than a fixed total).
315
385
  const stepPosition = computed(() => history.value.length + 1)
316
386
 
317
- defineExpose({ formData, currentKey, nextKey, isTerminal, progress, validateStep })
387
+ defineExpose({ formData, currentKey, nextKey, isTerminal, progress, completedKeys, validateStep })
318
388
  </script>
319
389
 
320
390
  <template>
321
- <div class="form-flow-player">
391
+ <div class="form-flow-player" :class="{ 'form-flow-player--stages': showStages }">
392
+ <nav v-if="showStages" class="ffp-stages" aria-label="Form stages">
393
+ <ol class="ffp-stage-list p-0 m-0">
394
+ <li v-for="(step, index) in pathSteps" :key="step.key">
395
+ <ListItem
396
+ rounded
397
+ color="purple"
398
+ :title="step.title"
399
+ :active="currentKey === step.key"
400
+ :disabled="!canOpenStage(step.key)"
401
+ :aria-current="currentKey === step.key ? 'step' : undefined"
402
+ @click="goToStage(step.key)"
403
+ >
404
+ <template #media>
405
+ <span
406
+ class="ffp-stage-number flex-center round border"
407
+ :class="currentKey === step.key
408
+ ? 'bg-primary color-white border-transparent'
409
+ : completedKeys.has(step.key)
410
+ ? 'bg-green color-white border-green'
411
+ : 'color-gray'"
412
+ >
413
+ <Icon v-if="completedKeys.has(step.key) && currentKey !== step.key" icon="check" :size="0.9" />
414
+ <template v-else>{{ index + 1 }}</template>
415
+ </span>
416
+ </template>
417
+ </ListItem>
418
+ </li>
419
+ </ol>
420
+ <div class="ffp-stage-progress">
421
+ <span>{{ currentStep?.title }}</span>
422
+ <span>{{ currentPathIndex + 1 }} / {{ pathSteps.length }}</span>
423
+ <Progress class="w-100p mt-05" :value="progress" :size="8" />
424
+ </div>
425
+ </nav>
322
426
  <div class="ffp-stage">
323
427
  <Transition :name="transitionName" mode="out-in">
324
428
  <div v-if="currentStep" :key="currentKey" class="ffp-step">
@@ -425,6 +529,29 @@ defineExpose({ formData, currentKey, nextKey, isTerminal, progress, validateStep
425
529
  gap: 1.5rem;
426
530
  }
427
531
 
532
+ .form-flow-player--stages {
533
+ display: grid;
534
+ grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
535
+ }
536
+
537
+ .ffp-stage-list {
538
+ list-style: none;
539
+ }
540
+
541
+ .ffp-stage-number {
542
+ width: 2rem;
543
+ height: 2rem;
544
+ }
545
+
546
+ .ffp-stage-progress {
547
+ display: none;
548
+ }
549
+
550
+ .form-flow-player--stages > .ffp-stage,
551
+ .form-flow-player--stages > .ffp-nav {
552
+ grid-column: 2;
553
+ }
554
+
428
555
  .ffp-title {
429
556
  margin: 0 0 1rem 0;
430
557
  font-size: 1.5rem;
@@ -470,4 +597,21 @@ defineExpose({ formData, currentKey, nextKey, isTerminal, progress, validateStep
470
597
  transform: none;
471
598
  }
472
599
  }
600
+
601
+ @media (max-width: 910px) {
602
+ .form-flow-player {
603
+ display: flex;
604
+ flex-direction: column;
605
+ }
606
+
607
+ .ffp-stage-list {
608
+ display: none;
609
+ }
610
+
611
+ .ffp-stage-progress {
612
+ display: flex;
613
+ flex-wrap: wrap;
614
+ justify-content: space-between;
615
+ }
616
+ }
473
617
  </style>