@bagelink/vue 1.15.207 → 1.15.209

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.
Files changed (32) hide show
  1. package/dist/components/AddressSearch.vue.d.ts.map +1 -1
  2. package/dist/components/Swiper.vue.d.ts +2 -2
  3. package/dist/components/calendar/CalendarPopover.vue.d.ts.map +1 -1
  4. package/dist/components/charts/MapChart.vue.d.ts +4 -0
  5. package/dist/components/charts/MapChart.vue.d.ts.map +1 -1
  6. package/dist/components/dataTable/DataTable.vue.d.ts +4 -2
  7. package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
  8. package/dist/components/dataTable/useTableVirtualization.d.ts +25 -0
  9. package/dist/components/dataTable/useTableVirtualization.d.ts.map +1 -0
  10. package/dist/components/form/inputs/ArrayInput.vue.d.ts +1 -1
  11. package/dist/components/form/inputs/DateInput.vue.d.ts.map +1 -1
  12. package/dist/components/form/inputs/SelectInput.vue.d.ts +1 -1
  13. package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
  14. package/dist/components/form/inputs/SignaturePad.vue.d.ts.map +1 -1
  15. package/dist/components/form/inputs/ToggleInput.vue.d.ts +1 -0
  16. package/dist/components/form/inputs/ToggleInput.vue.d.ts.map +1 -1
  17. package/dist/form-flow/FormFlow.vue.d.ts.map +1 -1
  18. package/dist/index.cjs +152 -175
  19. package/dist/index.mjs +37613 -31467
  20. package/dist/style.css +2 -1
  21. package/package.json +1 -1
  22. package/src/components/charts/MapChart.vue +191 -24
  23. package/src/components/dataTable/DataTable.vue +20 -4
  24. package/src/components/form/inputs/ArrayInput.vue +3 -3
  25. package/src/components/form/inputs/EmailInput.vue +1 -1
  26. package/src/components/form/inputs/NumberInput.vue +1 -1
  27. package/src/components/form/inputs/SelectInput.vue +3 -3
  28. package/src/components/form/inputs/SignaturePad.vue +0 -7
  29. package/src/components/form/inputs/TextInput.vue +1 -1
  30. package/src/components/form/inputs/ToggleInput.vue +8 -0
  31. package/src/form-flow/FormFlow.vue +9 -4
  32. package/src/styles/inputs.css +18 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.15.207",
4
+ "version": "1.15.209",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -5,21 +5,38 @@
5
5
  *
6
6
  * <MapChart :data="[{ id: 'US', value: 4200 }, { id: 'IL', label: 'Israel', value: 900 }]" />
7
7
  * <MapChart :data="rows" color="green" :height="280" />
8
+ * <MapChart :data="rows" :zoomable="false" />
8
9
  */
9
10
  import { computed, ref } from 'vue'
10
11
  import type { ThemeType } from '../../types'
12
+ import Btn from '../Btn.vue'
11
13
  import ChartTooltip from './ChartTooltip.vue'
12
14
  import { formatValue, type ValueFormat } from './core/format'
13
15
  import { resolveColor } from './core/palette'
14
16
  import { useChartAnim } from './core/useChartAnim'
15
17
  import { tipFollowStyle, trackLocalPointer } from './core/useChartTip'
16
- import { WORLD_NAMES, WORLD_PATHS, WORLD_VIEWBOX } from './core/world-paths'
18
+ import { WORLD_NAMES, WORLD_PATHS } from './core/world-paths'
17
19
  import type { MapDatum } from './mapTypes'
18
20
 
19
21
  defineOptions({ name: 'BglMapChart' })
20
22
 
21
23
  export type { MapDatum }
22
24
 
25
+ /**
26
+ * Crop empty ocean padding from Natural Earth sphere-fit (paths leave ~16%
27
+ * empty at the bottom and thin side margins inside 960×480).
28
+ */
29
+ const MAP_VIEW = {
30
+ x: 16,
31
+ y: 0,
32
+ width: 928,
33
+ height: 412,
34
+ } as const
35
+
36
+ const MIN_ZOOM = 1
37
+ const MAX_ZOOM = 8
38
+ const ZOOM_STEP = 1.35
39
+
23
40
  const props = withDefaults(defineProps<{
24
41
  data: MapDatum[]
25
42
  /** Base tone for the value ramp (low → high intensity). */
@@ -27,6 +44,8 @@ const props = withDefaults(defineProps<{
27
44
  height?: number
28
45
  /** Show min→max legend strip under the map. */
29
46
  legend?: boolean
47
+ /** Wheel + button zoom / drag-to-pan. */
48
+ zoomable?: boolean
30
49
  currency?: string
31
50
  prefix?: string
32
51
  suffix?: string
@@ -35,10 +54,12 @@ const props = withDefaults(defineProps<{
35
54
  color: 'primary',
36
55
  height: 280,
37
56
  legend: true,
57
+ zoomable: true,
38
58
  animated: true,
39
59
  })
40
60
 
41
61
  const el = ref<HTMLElement>()
62
+ const svgEl = ref<SVGSVGElement>()
42
63
  const { progress } = useChartAnim({ el, enabled: props.animated, duration: 700 })
43
64
 
44
65
  const fmt = computed<ValueFormat>(() => ({
@@ -64,8 +85,8 @@ const byId = computed(() => {
64
85
  const maxValue = computed(() => Math.max(0, ...[...byId.value.values()].map(d => d.value)))
65
86
 
66
87
  const baseColor = computed(() => resolveColor(props.color))
67
- const emptyFill = 'color-mix(in srgb, var(--bgl-gray) 12%, var(--bgl-box-bg))'
68
- const strokeColor = 'color-mix(in srgb, var(--bgl-gray) 28%, var(--bgl-box-bg))'
88
+ const emptyFill = 'var(--bgl-bg)'
89
+ const strokeColor = 'var(--bgl-border-color)'
69
90
 
70
91
  /** Continuous ramp: empty → light tint → full base color by value share. */
71
92
  function fillFor(value: number | undefined): string {
@@ -103,12 +124,17 @@ const hoverId = ref<string | null>(null)
103
124
  const tipX = ref(0)
104
125
  const tipY = ref(0)
105
126
 
106
- function onEnter(id: string) { hoverId.value = id }
127
+ function onEnter(id: string) {
128
+ if (dragging.value) return
129
+ hoverId.value = id
130
+ }
107
131
  function onLeave() { hoverId.value = null }
108
- function onMove(e: MouseEvent) { trackLocalPointer(e, tipX, tipY) }
132
+ function onMove(e: MouseEvent) {
133
+ if (!dragging.value) trackLocalPointer(e, tipX, tipY)
134
+ }
109
135
 
110
136
  const hover = computed(() => {
111
- if (!hoverId.value) return null
137
+ if (!hoverId.value || dragging.value) return null
112
138
  const c = countries.value.find(x => x.id === hoverId.value)
113
139
  return c?.hasData ? c : null
114
140
  })
@@ -121,41 +147,178 @@ const tooltipStyle = tipFollowStyle(tipX, tipY)
121
147
 
122
148
  const legendMin = computed(() => formatValue(0, fmt.value))
123
149
  const legendMax = computed(() => formatValue(maxValue.value, fmt.value))
150
+
151
+ // ── Zoom / pan ──────────────────────────────────────────────────────────────
152
+ const scale = ref(1)
153
+ const tx = ref(0)
154
+ const ty = ref(0)
155
+ const dragging = ref(false)
156
+ const dragOrigin = ref<{ x: number; y: number; tx: number; ty: number } | null>(null)
157
+
158
+ const mapTransform = computed(() =>
159
+ `translate(${tx.value} ${ty.value}) scale(${scale.value})`,
160
+ )
161
+
162
+ const isZoomed = computed(() => scale.value > MIN_ZOOM + 0.001)
163
+
164
+ function clamp(n: number, min: number, max: number) {
165
+ return Math.min(max, Math.max(min, n))
166
+ }
167
+
168
+ /** Client → SVG user coords (cropped viewBox space). */
169
+ function clientToSvg(clientX: number, clientY: number) {
170
+ const svg = svgEl.value
171
+ if (!svg) return { x: 0, y: 0 }
172
+ const pt = svg.createSVGPoint()
173
+ pt.x = clientX
174
+ pt.y = clientY
175
+ const ctm = svg.getScreenCTM()?.inverse()
176
+ if (!ctm) return { x: 0, y: 0 }
177
+ const p = pt.matrixTransform(ctm)
178
+ return { x: p.x, y: p.y }
179
+ }
180
+
181
+ function zoomAt(clientX: number, clientY: number, next: number) {
182
+ const newScale = clamp(next, MIN_ZOOM, MAX_ZOOM)
183
+ if (newScale === scale.value) return
184
+ const { x, y } = clientToSvg(clientX, clientY)
185
+ const k = newScale / scale.value
186
+ tx.value = x - (x - tx.value) * k
187
+ ty.value = y - (y - ty.value) * k
188
+ scale.value = newScale
189
+ if (newScale <= MIN_ZOOM) {
190
+ tx.value = 0
191
+ ty.value = 0
192
+ scale.value = MIN_ZOOM
193
+ }
194
+ }
195
+
196
+ function onWheel(e: WheelEvent) {
197
+ if (!props.zoomable) return
198
+ e.preventDefault()
199
+ const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
200
+ zoomAt(e.clientX, e.clientY, scale.value * factor)
201
+ }
202
+
203
+ function zoomBy(factor: number) {
204
+ const svg = svgEl.value
205
+ if (!svg) return
206
+ const r = svg.getBoundingClientRect()
207
+ zoomAt(r.left + r.width / 2, r.top + r.height / 2, scale.value * factor)
208
+ }
209
+
210
+ function resetZoom() {
211
+ scale.value = MIN_ZOOM
212
+ tx.value = 0
213
+ ty.value = 0
214
+ }
215
+
216
+ function onPointerDown(e: PointerEvent) {
217
+ if (!props.zoomable || !isZoomed.value || e.button !== 0) return
218
+ dragging.value = true
219
+ hoverId.value = null
220
+ const p = clientToSvg(e.clientX, e.clientY)
221
+ dragOrigin.value = { x: p.x, y: p.y, tx: tx.value, ty: ty.value }
222
+ ;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
223
+ }
224
+
225
+ function onPointerMove(e: PointerEvent) {
226
+ if (!dragging.value || !dragOrigin.value) return
227
+ const p = clientToSvg(e.clientX, e.clientY)
228
+ tx.value = dragOrigin.value.tx + (p.x - dragOrigin.value.x)
229
+ ty.value = dragOrigin.value.ty + (p.y - dragOrigin.value.y)
230
+ }
231
+
232
+ function onPointerUp(e: PointerEvent) {
233
+ if (!dragging.value) return
234
+ dragging.value = false
235
+ dragOrigin.value = null
236
+ ;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
237
+ }
238
+
239
+ const croppedViewBox = `${MAP_VIEW.x} ${MAP_VIEW.y} ${MAP_VIEW.width} ${MAP_VIEW.height}`
124
240
  </script>
125
241
 
126
242
  <template>
127
243
  <div ref="el" class="bgl-map w-100p flex column gap-075">
128
244
  <div
129
- class="bgl-map__plot relative w-100p"
245
+ class="bgl-map__plot relative w-100p overflow-hidden"
246
+ :class="{
247
+ 'bgl-map__plot--zoomable': zoomable,
248
+ 'bgl-map__plot--zoomed': zoomable && isZoomed,
249
+ 'bgl-map__plot--dragging': dragging,
250
+ }"
130
251
  :style="{ height: `${height}px` }"
131
252
  @mousemove="onMove"
132
253
  @mouseleave="onLeave"
254
+ @wheel="onWheel"
255
+ @pointerdown="onPointerDown"
256
+ @pointermove="onPointerMove"
257
+ @pointerup="onPointerUp"
258
+ @pointercancel="onPointerUp"
133
259
  >
134
260
  <svg
261
+ ref="svgEl"
135
262
  class="bgl-map__svg"
136
263
  dir="ltr"
137
- :viewBox="`0 0 ${WORLD_VIEWBOX.width} ${WORLD_VIEWBOX.height}`"
264
+ :viewBox="croppedViewBox"
138
265
  preserveAspectRatio="xMidYMid meet"
139
266
  width="100%"
140
267
  height="100%"
141
268
  >
142
- <path
143
- v-for="c in countries"
144
- :key="c.id"
145
- :d="c.d"
146
- :fill="c.fill"
147
- :stroke="strokeColor"
148
- stroke-width="0.6"
149
- vector-effect="non-scaling-stroke"
150
- class="bgl-map__country"
151
- :class="{
152
- 'opacity-5': hoverId != null && hoverId !== c.id,
153
- 'bgl-map__country--hot': hoverId === c.id && c.hasData,
154
- }"
155
- @mouseenter="onEnter(c.id)"
156
- />
269
+ <g :transform="mapTransform">
270
+ <path
271
+ v-for="c in countries"
272
+ :key="c.id"
273
+ :d="c.d"
274
+ :fill="c.fill"
275
+ :stroke="strokeColor"
276
+ stroke-width="0.6"
277
+ vector-effect="non-scaling-stroke"
278
+ class="bgl-map__country"
279
+ :class="{
280
+ 'opacity-5': hoverId != null && hoverId !== c.id,
281
+ 'bgl-map__country--hot': hoverId === c.id && c.hasData,
282
+ }"
283
+ @mouseenter="onEnter(c.id)"
284
+ />
285
+ </g>
157
286
  </svg>
158
287
 
288
+ <div
289
+ v-if="zoomable"
290
+ class="bgl-map__zoom flex column gap-025 absolute bottom-05 end-025 z-1 p-025 round bg-box shadow-10 border"
291
+ @pointerdown.stop
292
+ >
293
+ <Btn
294
+ flat
295
+ thin
296
+ round
297
+ size="xs"
298
+ icon="add"
299
+ :disabled="scale >= MAX_ZOOM"
300
+ @click="zoomBy(ZOOM_STEP)"
301
+ />
302
+ <Btn
303
+ flat
304
+ thin
305
+ round
306
+ size="xs"
307
+ icon="remove"
308
+ :disabled="scale <= MIN_ZOOM"
309
+ @click="zoomBy(1 / ZOOM_STEP)"
310
+ />
311
+ <Btn
312
+ :disabled="!isZoomed"
313
+ flat
314
+ thin
315
+ round
316
+ size="xs"
317
+ icon="zoom_out_map"
318
+ @click="resetZoom"
319
+ />
320
+ </div>
321
+
159
322
  <ChartTooltip
160
323
  v-if="hover"
161
324
  prefer="above"
@@ -183,12 +346,16 @@ const legendMax = computed(() => formatValue(maxValue.value, fmt.value))
183
346
 
184
347
  <style scoped>
185
348
  .bgl-map__plot { min-height: 0; }
186
- .bgl-map__svg { display: block; overflow: visible; }
349
+ .bgl-map__plot--zoomable { touch-action: none; }
350
+ .bgl-map__plot--zoomed { cursor: grab; }
351
+ .bgl-map__plot--dragging { cursor: grabbing; }
352
+ .bgl-map__svg { display: block; overflow: hidden; }
187
353
  .bgl-map__country {
188
354
  cursor: default;
189
355
  transition: opacity 0.15s ease, fill 0.15s ease;
190
356
  outline: none;
191
357
  }
358
+ .bgl-map__plot--zoomed .bgl-map__country { cursor: inherit; }
192
359
  .bgl-map__country--hot { stroke: var(--bgl-gray); stroke-width: 1.2; }
193
360
  .bgl-map__ramp {
194
361
  height: 0.5rem;
@@ -32,6 +32,8 @@ const slots: SetupContext['slots'] = useSlots()
32
32
 
33
33
  const loading = defineModel('loading', { default: false })
34
34
  const itemHeight = defineModel('itemHeight', { default: 50 })
35
+ /** Cell font size. Number = px; string = any CSS size (e.g. '0.875rem'). */
36
+ const fontSize = defineModel<number | string | undefined>('fontSize', { default: undefined })
35
37
  const selectedItems = defineModel<string[]>('selectedItems', { default: [] as string[] })
36
38
 
37
39
  const {
@@ -150,6 +152,20 @@ const showLoading = computed(() => loading.value)
150
152
 
151
153
  // Show the empty state only once loading has settled and there are no rows.
152
154
  const isEmpty = computed(() => !showLoading.value && computedData.value.length === 0)
155
+
156
+ const resolvedFontSize = computed(() => {
157
+ const size = fontSize.value
158
+ if (size == null || size === '') return null
159
+ return typeof size === 'number' ? `${size}px` : String(size)
160
+ })
161
+
162
+ /** Applied to body cells — falls back to the historical default. */
163
+ const bodyFontSize = computed(() => resolvedFontSize.value ?? '0.88em')
164
+ /** Header is slightly smaller than body when a custom size is set. */
165
+ const headerFontSize = computed(() => {
166
+ if (!resolvedFontSize.value) return '0.8rem'
167
+ return `calc(${resolvedFontSize.value} * 0.9)`
168
+ })
153
169
  </script>
154
170
 
155
171
  <template>
@@ -292,7 +308,7 @@ width: 100%;
292
308
  }
293
309
 
294
310
  th {
295
- font-size: 0.8rem;
311
+ font-size: v-bind(headerFontSize);
296
312
  color: var(--bgl-black-tint);
297
313
  position: sticky;
298
314
  top: 0;
@@ -315,7 +331,7 @@ cursor: pointer;
315
331
  }
316
332
 
317
333
  .row.first-row {
318
- font-size: 0.8rem;
334
+ font-size: v-bind(headerFontSize);
319
335
  color: var(--bgl-black-tint);
320
336
  position: sticky;
321
337
  top: 0;
@@ -377,7 +393,7 @@ margin-top: -2px;
377
393
  }
378
394
 
379
395
  .rows {
380
- font-size: 0.88em;
396
+ font-size: v-bind(bodyFontSize);
381
397
  }
382
398
 
383
399
  .table-list {
@@ -476,7 +492,7 @@ line-height: 1;
476
492
  }
477
493
 
478
494
  tbody tr {
479
- font-size: 0.88em;
495
+ font-size: v-bind(bodyFontSize);
480
496
  height: v-bind(computedItemHeight);
481
497
  transition: all 200ms ease;
482
498
  }
@@ -11,7 +11,7 @@ import { useBagelInputShell } from './bagelInputShell'
11
11
 
12
12
  const props = defineProps<{
13
13
  label?: string
14
- helpText?: string
14
+ helptext?: string
15
15
  modelValue: T[]
16
16
  allowAdd?: boolean
17
17
  allowDelete?: boolean
@@ -176,8 +176,8 @@ function getItemRef(i: number) {
176
176
  <label v-if="label" class="txt12 txt-gray-90 mb-025 block">
177
177
  {{ resolveI18n(label) }}
178
178
  </label>
179
- <p v-if="helpText" class="txt12 txt-gray-50 mb-05">
180
- {{ helpText }}
179
+ <p v-if="helptext" class="helptext">
180
+ {{ helptext }}
181
181
  </p>
182
182
  <template v-if="!collapsible">
183
183
  <div v-for="(_, i) in items" :key="i" class="array-input-row grid align-items-center" :class="{ 'simpleArray': isSimple, 'pb-1': !isSimple }">
@@ -351,7 +351,7 @@ onMounted(() => {
351
351
  <label :for="id">
352
352
  <span v-if="label || (underlined && placeholder)" class="label-text">{{ resolveI18n(label) || resolveI18n(placeholder) }}<span v-if="required"> *</span></span>
353
353
  <div v-if="!underlined" class="flex">
354
- <span v-if="helptext" class="opacity-7 light">{{ helptext }}</span>
354
+ <span v-if="helptext" class="helptext">{{ helptext }}</span>
355
355
  <span
356
356
  v-if="suggestedCorrection" class="pointer nowrap inline-block ms-auto color-red txt-10px p-0"
357
357
  @click.prevent="applyCorrection"
@@ -217,7 +217,7 @@ const hasValue = computed(() => formattedValue.value.length > 0)
217
217
  :class="[{ 'bgl-big-ctrl-num-btn': layout === 'vertical' }]" tabindex="-1" @click="decrement"
218
218
  />
219
219
  </div>
220
- <p v-if="helptext" class="opacity-7 light">
220
+ <p v-if="helptext" class="helptext">
221
221
  {{ helptext }}
222
222
  </p>
223
223
  </div>
@@ -36,7 +36,7 @@ interface PropTypes extends BagelInputShellProps {
36
36
  icon?: IconType
37
37
  hideLabel?: boolean
38
38
  defaultValue?: Option
39
- helpText?: string
39
+ helptext?: string
40
40
  clearable?: boolean
41
41
  placement?: AlignedPlacement
42
42
  searchPlaceholder?: string
@@ -315,8 +315,8 @@ onMounted(() => {
315
315
  {{ error }}
316
316
  </div>
317
317
  </label>
318
- <p v-if="helpText" class="opacity-7 txt-small light">
319
- {{ helpText }}
318
+ <p v-if="helptext" class="helptext">
319
+ {{ helptext }}
320
320
  </p>
321
321
  </template>
322
322
  <Skeleton v-if="isLoading" :count="6" width="200px" height="25px" class="mx-1 my-1" />
@@ -294,13 +294,6 @@ font-size: var(--bgl-label-font-size);
294
294
  margin-bottom: 0.25rem;
295
295
  }
296
296
 
297
- .bgl_input.signature-pad > .helptext {
298
- font-size: var(--bgl-label-font-size);
299
- margin: 0 0 0.25rem 0;
300
- line-height: 1.4;
301
- opacity: 0.7;
302
- }
303
-
304
297
  .bgl_input.signature-pad .canvas[disabled] {
305
298
  background: var(--input-disabled-bg);
306
299
  border: 1px solid var(--bgl-gray);
@@ -186,10 +186,10 @@ onMounted(async () => {
186
186
  :placeholder="underlined ? ' ' : (resolveI18n(placeholder) || resolveI18n(label))" :disabled :required
187
187
  v-bind="nativeInputAttrs" :pattern="normalizedPattern" @input="updateInputVal" @focusout="onFocusout"
188
188
  />
189
- <p v-if="helptext" class="opacity-7 light">{{ helptext }}</p>
190
189
  <Icon v-if="iconStart" :class="{ ' -top-025 line-height-0 ': underlined }" class="iconStart" :icon="iconStart" />
191
190
  <Icon v-if="icon" :class="{ ' -top-025 line-height-0 ': underlined }" :icon />
192
191
  </label>
192
+ <p v-if="helptext" class="helptext">{{ helptext }}</p>
193
193
  <div v-if="error" class="error-message">
194
194
  {{ error }}
195
195
  </div>
@@ -8,6 +8,7 @@ const props = withDefaults(defineProps<{
8
8
  label?: string
9
9
  id?: string
10
10
  title?: string
11
+ helptext?: string
11
12
  small?: boolean
12
13
  required?: boolean
13
14
  defaultValue?: boolean
@@ -47,6 +48,10 @@ onMounted(() => {
47
48
  {{ resolveI18n(label) }}
48
49
  </slot>
49
50
  </label>
51
+
52
+ <p v-if="helptext" class="helptext">
53
+ {{ helptext }}
54
+ </p>
50
55
  </div>
51
56
  </template>
52
57
 
@@ -58,6 +63,9 @@ display: unset !important;
58
63
  .bagel-input.bgl-toggle *{
59
64
  cursor: pointer !important;
60
65
  }
66
+ .bagel-input.bgl-toggle .helptext {
67
+ cursor: default !important;
68
+ }
61
69
  .bagel-input.bgl-toggle input{
62
70
  padding: unset !important;
63
71
  min-width: unset !important;
@@ -293,12 +293,15 @@ const mergedErrors = computed(() => {
293
293
 
294
294
  function getFieldProps(field: FieldBuilder, key: string) {
295
295
  const label = generateLabel(key, field)
296
+ const { helpText, ...configRest } = field._config
296
297
  const baseProps: any = {
297
298
  label,
298
299
  'modelValue': getFieldValue(key),
299
300
  'onUpdate:modelValue': (val: any) => { updateField(key, val, field) },
300
301
  'error': mergedErrors.value[key],
301
- ...field._config,
302
+ ...configRest,
303
+ // Inputs use `helptext`; FormFlow schema API keeps `helpText`
304
+ ...(helpText != null ? { helptext: helpText } : {}),
302
305
  name: field._config.name ?? key,
303
306
  }
304
307
 
@@ -326,14 +329,16 @@ function getFieldProps(field: FieldBuilder, key: string) {
326
329
 
327
330
  if (field._type === 'array') {
328
331
  // `default` / `schema` are form-schema metadata — never pass them through to ArrayInput
329
- // (and the earlier `...field._config` spread may have already set them).
332
+ // (and the earlier config spread may have already set them).
330
333
  delete baseProps.default
331
334
  delete baseProps.schema
332
335
  delete baseProps.itemType
333
- const configWithoutSchema = Object.fromEntries(
336
+ const { helpText: arrayHelpText, ...arrayConfigRest } = Object.fromEntries(
334
337
  Object.entries(field._config).filter(([k]) => k !== 'schema' && k !== 'itemType' && k !== 'default')
335
338
  )
336
- Object.assign(baseProps, configWithoutSchema)
339
+ Object.assign(baseProps, arrayConfigRest)
340
+ if (arrayHelpText != null) baseProps.helptext = arrayHelpText
341
+ delete baseProps.helpText
337
342
  // Re-apply binding after config spread so handlers never get overwritten.
338
343
  baseProps.modelValue = getFieldValue(key)
339
344
  baseProps['onUpdate:modelValue'] = (val: any) => { updateField(key, val, field) }
@@ -59,6 +59,24 @@ select {
59
59
  color: var(--bgl-input-label-active-color);
60
60
  }
61
61
 
62
+ .helptext {
63
+ display: block;
64
+ margin: 0.15rem 0 0.25rem;
65
+ line-height: 1.4;
66
+ font-size: var(--bgl-label-font-size);
67
+ font-weight: 300;
68
+ color: var(--bgl-label-color);
69
+ cursor: default;
70
+ user-select: text;
71
+ }
72
+
73
+ /* Keep helptext muted even when nested inside a focused/active label */
74
+ .bagel-input:focus-within .helptext,
75
+ .bagel-input.open .helptext,
76
+ .bagel-input:has(:checked) .helptext {
77
+ color: var(--bgl-label-color);
78
+ }
79
+
62
80
  .bagel-input input,
63
81
  .bagel-input select,
64
82
  .custom-select .input {