@bagelink/vue 1.15.401 → 1.15.409

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/form/inputs/DateInput.vue.d.ts.map +1 -1
  2. package/dist/components/form/inputs/DatePicker.vue.d.ts +2 -0
  3. package/dist/components/form/inputs/DatePicker.vue.d.ts.map +1 -1
  4. package/dist/components/form/inputs/RichText/index.vue.d.ts +3 -1
  5. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  6. package/dist/components/form/inputs/RichText/richTextTypes.d.ts +10 -0
  7. package/dist/components/form/inputs/RichText/richTextTypes.d.ts.map +1 -1
  8. package/dist/components/form/inputs/index.d.ts +2 -1
  9. package/dist/components/form/inputs/index.d.ts.map +1 -1
  10. package/dist/core.css +2 -2
  11. package/dist/form-flow/FormFlow.vue.d.ts.map +1 -1
  12. package/dist/form-flow/form-flow.d.ts +6 -2
  13. package/dist/form-flow/form-flow.d.ts.map +1 -1
  14. package/dist/form-flow/grid-classes.d.ts +19 -1
  15. package/dist/form-flow/grid-classes.d.ts.map +1 -1
  16. package/dist/form-flow/schema-fields.d.ts +12 -2
  17. package/dist/form-flow/schema-fields.d.ts.map +1 -1
  18. package/dist/index.cjs +47 -45
  19. package/dist/index.css +1 -1
  20. package/dist/index.mjs +10402 -10308
  21. package/dist/style.css +2 -2
  22. package/package.json +1 -1
  23. package/src/components/form/inputs/DateInput.vue +7 -0
  24. package/src/components/form/inputs/DatePicker.vue +2 -1
  25. package/src/components/form/inputs/RichText/index.vue +16 -3
  26. package/src/components/form/inputs/RichText/richTextTypes.ts +14 -0
  27. package/src/components/form/inputs/index.ts +2 -1
  28. package/src/form-flow/FormFlow.vue +42 -1
  29. package/src/form-flow/form-flow.ts +10 -3
  30. package/src/form-flow/grid-classes.ts +94 -4
  31. package/src/form-flow/schema-fields.ts +32 -5
  32. package/src/styles/inputs.css +34 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@bagelink/vue",
3
3
  "type": "module",
4
- "version": "1.15.401",
4
+ "version": "1.15.409",
5
5
  "description": "Bagel core sdk packages",
6
6
  "author": {
7
7
  "name": "Bagel Studio",
@@ -275,6 +275,12 @@ function handleIconClick() {
275
275
  isOpen.value = !isOpen.value
276
276
  }
277
277
 
278
+ function confirmSelection() {
279
+ isTyping.value = false
280
+ syncInputValue()
281
+ isOpen.value = false
282
+ }
283
+
278
284
  // Sync input display when date changes externally; coerce datetime → date-only.
279
285
  watch(() => selectedDate.value, (val) => {
280
286
  const coerced = coerceDateOnlyValue(val)
@@ -335,6 +341,7 @@ onMounted(() => {
335
341
  v-model="selectedDate" :min="min" :max="max" :mode="mode"
336
342
  :firstDayOfWeek="firstDayOfWeek" :locale="locale" :enableTime="enableTime"
337
343
  :range="range"
344
+ @confirm="confirmSelection"
338
345
  />
339
346
  </div>
340
347
  </Dropdown>
@@ -39,7 +39,7 @@ const props = withDefaults(
39
39
  },
40
40
  )
41
41
 
42
- const emit = defineEmits(['update:modelValue'])
42
+ const emit = defineEmits(['update:modelValue', 'confirm'])
43
43
 
44
44
  // Tentative end date hovered during range selection
45
45
  const hoverDate = ref<Date | null>(null)
@@ -542,6 +542,7 @@ function selectDate(date: Date | null) {
542
542
  layout="vertical" @update:modelValue="handleMinuteInput"
543
543
  />
544
544
  </div>
545
+ <Btn value="OK" :disabled="!selectedDate" @click="emit('confirm')" />
545
546
  </div>
546
547
  </div>
547
548
  </template>
@@ -1,6 +1,6 @@
1
1
  <!-- eslint-disable vue/multi-word-component-names -->
2
2
  <script setup lang="ts">
3
- import type { CustomToolbarButton, RichTextToolbarApi, ToolbarConfig } from './richTextTypes'
3
+ import type { CustomToolbarButton, RichTextExtension, RichTextToolbarApi, ToolbarConfig } from './richTextTypes'
4
4
  import { CodeEditor, copyText, Btn, Dialog, BglVideo, Icon, Card, ColorInput, pathKeyToURL } from '@bagelink/vue'
5
5
  import { watch, onUnmounted, onBeforeUnmount, onMounted, ref, computed, useAttrs } from 'vue'
6
6
  import CheckInput from '../CheckInput.vue'
@@ -24,6 +24,7 @@ const props = defineProps<{
24
24
  modelValue: string
25
25
  toolbarConfig?: ToolbarConfig
26
26
  customToolbarButtons?: CustomToolbarButton[]
27
+ extensions?: RichTextExtension[]
27
28
  debug?: boolean
28
29
  label?: string
29
30
  height?: number | string
@@ -50,12 +51,23 @@ const props = defineProps<{
50
51
  textColor?: string
51
52
  // Font size
52
53
  fontSize?: number | string
54
+ // Field-specific CSS injected into the editor iframe.
55
+ contentStyles?: string
53
56
  } & BagelInputShellProps>()
54
57
 
55
58
  const emit = defineEmits(['update:modelValue'])
56
59
 
57
60
  const { shellClass, shellStyle } = useBagelInputShell(props)
58
61
  const hasEditorValue = computed(() => Boolean(props.modelValue?.trim()))
62
+ const extensionToolbarButtons = computed(() => props.extensions?.flatMap(extension => extension.toolbarButtons ?? []) ?? [])
63
+ const effectiveCustomToolbarButtons = computed(() => [
64
+ ...(props.customToolbarButtons ?? []),
65
+ ...extensionToolbarButtons.value,
66
+ ])
67
+ const effectiveContentStyles = computed(() => [
68
+ props.contentStyles,
69
+ ...(props.extensions?.map(extension => extension.contentStyles) ?? []),
70
+ ].filter(Boolean).join('\n'))
59
71
 
60
72
  const attrs = useAttrs()
61
73
 
@@ -3294,13 +3306,14 @@ function updateIframeColors() {
3294
3306
  color: inherit !important;
3295
3307
  font-size: inherit !important;
3296
3308
  }
3309
+ ${effectiveContentStyles.value}
3297
3310
  `
3298
3311
 
3299
3312
  styleElement.textContent = css
3300
3313
  }
3301
3314
 
3302
3315
  // Watch for theme changes and update iframe colors
3303
- watch([currentInputColor, currentTextColor, currentFontFamily], () => {
3316
+ watch([currentInputColor, currentTextColor, currentFontFamily, effectiveContentStyles], () => {
3304
3317
  if (hasInitialized.value) {
3305
3318
  updateIframeColors()
3306
3319
  }
@@ -3423,7 +3436,7 @@ const imgTransform = computed({
3423
3436
  <template #end>
3424
3437
  <slot name="toolbar-end" :api="toolbarApi">
3425
3438
  <Btn
3426
- v-for="button in customToolbarButtons"
3439
+ v-for="button in effectiveCustomToolbarButtons"
3427
3440
  :key="button.name"
3428
3441
  v-tooltip="button.label"
3429
3442
  :icon="button.icon"
@@ -77,6 +77,20 @@ export interface CustomToolbarButton {
77
77
  action: (api: RichTextToolbarApi) => void
78
78
  }
79
79
 
80
+ /**
81
+ * A reusable editor capability. Extensions can add toolbar actions and the CSS
82
+ * needed to preview their generated HTML inside the editor iframe.
83
+ */
84
+ export interface RichTextExtension {
85
+ name: string
86
+ toolbarButtons?: CustomToolbarButton[]
87
+ contentStyles?: string
88
+ }
89
+
90
+ export function defineRichTextExtension<T extends RichTextExtension>(extension: T): T {
91
+ return extension
92
+ }
93
+
80
94
  export interface ToolbarOption {
81
95
  name: string
82
96
  label?: string
@@ -15,7 +15,8 @@ export { default as PasswordInput } from './PasswordInput.vue'
15
15
  export { default as RadioGroup } from './RadioGroup.vue'
16
16
  export { default as RangeInput } from './RangeInput.vue'
17
17
  export { default as RichText } from './RichText/index.vue'
18
- export type { CustomToolbarButton, RichTextToolbarApi } from './RichText/richTextTypes'
18
+ export { defineRichTextExtension } from './RichText/richTextTypes'
19
+ export type { CustomToolbarButton, RichTextExtension, RichTextToolbarApi } from './RichText/richTextTypes'
19
20
  export { default as SelectBtn } from './SelectBtn.vue'
20
21
  export { default as SelectInput } from './SelectInput.vue'
21
22
  export { default as SignaturePad } from './SignaturePad.vue'
@@ -23,6 +23,9 @@ import Divider from '../components/layout/Divider.vue'
23
23
  import { useI18n, resolveI18n } from '../i18n'
24
24
  import { evaluateQuery } from '../utils/queryFilter'
25
25
  import { EMAIL_REGEX } from '../utils/constants'
26
+ import {
27
+ classToGridSpans,
28
+ } from './grid-classes'
26
29
 
27
30
  export type FormState = 'idle' | 'submitting' | 'success' | 'error'
28
31
 
@@ -220,6 +223,23 @@ const visibleFields = computed(() => {
220
223
  return fields
221
224
  })
222
225
 
226
+ function fieldLayoutStyle(field: FieldBuilder): Record<string, string> | undefined {
227
+ if (!props.schema._class?.split(/\s+/).includes('free-grid')) return undefined
228
+ const {
229
+ rowStart,
230
+ rowSpan,
231
+ mRowStart: mobileRowStart,
232
+ mRowSpan: mobileRowSpan,
233
+ } = classToGridSpans(field._class)
234
+ if (!rowStart && !mobileRowStart && !rowSpan && !mobileRowSpan) return undefined
235
+ return {
236
+ ...(rowStart ? { '--bgl-grid-row-start': String(rowStart) } : {}),
237
+ ...(mobileRowStart ? { '--bgl-mobile-grid-row-start': String(mobileRowStart) } : {}),
238
+ ...(rowSpan ? { '--bgl-grid-row-span': String(rowSpan) } : {}),
239
+ ...(mobileRowSpan ? { '--bgl-mobile-grid-row-span': String(mobileRowSpan) } : {}),
240
+ }
241
+ }
242
+
223
243
  function updateField(key: string, value: any, field: FieldBuilder) {
224
244
  setNestedValue(formData.value, key, value)
225
245
  if (formState.value === 'success' || formState.value === 'error') {
@@ -338,6 +358,7 @@ const mergedErrors = computed(() => {
338
358
  function getFieldProps(field: FieldBuilder, key: string) {
339
359
  const label = generateLabel(key, field)
340
360
  const { helpText, ...configRest } = field._config
361
+ const freeGrid = props.schema._class?.split(/\s+/).includes('free-grid') ?? false
341
362
  const baseProps: any = {
342
363
  label,
343
364
  'modelValue': getFieldValue(key),
@@ -357,6 +378,21 @@ function getFieldProps(field: FieldBuilder, key: string) {
357
378
  baseProps.multiselect = true
358
379
  }
359
380
 
381
+ if (freeGrid && field._type === 'richtext') {
382
+ baseProps.fullHeight = true
383
+ }
384
+
385
+ if (
386
+ freeGrid
387
+ && (field._type === 'upload' || field._type === 'file')
388
+ ) {
389
+ // In free-grid, row span owns the field height. Ignore a persisted
390
+ // fixed upload height so the preview grows and shrinks with its cell.
391
+ baseProps.height = field._config.hideLabel
392
+ ? '100%'
393
+ : 'calc(100% - 1.75rem)'
394
+ }
395
+
360
396
  // Function option loaders get the live form data as a 2nd arg so options can
361
397
  // depend on sibling fields (e.g. `model` filtered by the selected `provider`).
362
398
  // SelectInput/useSearch only re-run the loader on search-term change; a change
@@ -502,7 +538,12 @@ defineExpose({
502
538
  <slot name="success" :form-data="formData" />
503
539
  </template>
504
540
  <template v-else>
505
- <div v-for="{ key, field } in visibleFields" :key="fieldKey(key, field)" :class="[field._class, field._config.class]">
541
+ <div
542
+ v-for="{ key, field } in visibleFields"
543
+ :key="fieldKey(key, field)"
544
+ :class="[field._class, field._config.class]"
545
+ :style="fieldLayoutStyle(field)"
546
+ >
506
547
  <!-- Custom slot for specific field -->
507
548
  <slot
508
549
  :name="`field:${key}`"
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { Component } from 'vue'
12
12
  import type { IconType } from '../types'
13
- import type { CustomToolbarButton } from '../components/form/inputs/RichText/richTextTypes'
13
+ import type { CustomToolbarButton, RichTextExtension } from '../components/form/inputs/RichText/richTextTypes'
14
14
  import { getOptionValue } from '../utils/options'
15
15
 
16
16
  // ============================================================================
@@ -298,8 +298,8 @@ export const $ = {
298
298
  },
299
299
 
300
300
  richtext(
301
- labelOrConfig?: string | (BaseFieldConfig & { autoheight?: boolean, basic?: boolean, simple?: boolean, fontSize?: number | string, customToolbarButtons?: CustomToolbarButton[] }),
302
- config?: BaseFieldConfig & { autoheight?: boolean, basic?: boolean, simple?: boolean, fontSize?: number | string, customToolbarButtons?: CustomToolbarButton[] }
301
+ labelOrConfig?: string | (BaseFieldConfig & { autoheight?: boolean, basic?: boolean, simple?: boolean, fontSize?: number | string, customToolbarButtons?: CustomToolbarButton[], contentStyles?: string, extensions?: RichTextExtension[] }),
302
+ config?: BaseFieldConfig & { autoheight?: boolean, basic?: boolean, simple?: boolean, fontSize?: number | string, customToolbarButtons?: CustomToolbarButton[], contentStyles?: string, extensions?: RichTextExtension[] }
303
303
  ): FieldBuilder<string> {
304
304
  return new Field('richtext', parseArgs(labelOrConfig, config))
305
305
  },
@@ -1365,11 +1365,18 @@ export {
1365
1365
  DEFAULT_ROW_SPAN,
1366
1366
  colSpanToClass,
1367
1367
  colStartToClass,
1368
+ mColStartToClass,
1368
1369
  mColSpanToClass,
1370
+ mRowStartToClass,
1371
+ mRowSpanToClass,
1369
1372
  rowSpanToClass,
1370
1373
  classToColSpan,
1371
1374
  classToColStart,
1375
+ classToMColStart,
1372
1376
  classToMColSpan,
1377
+ classToMRowStart,
1378
+ classToMRowSpan,
1379
+ classToRowStart,
1373
1380
  classToRowSpan,
1374
1381
  classToGridSpans,
1375
1382
  stripGridClasses,
@@ -8,7 +8,11 @@
8
8
  * Desktop column span: col-{1..12}/12 (e.g. col-6/12 = span 6)
9
9
  * Desktop column start: start-col-{1..12}
10
10
  * Mobile column span: m_col-{1..12}/12
11
- * Row span: span-row-{1..12} (e.g. span-row-2 = span 2 rows)
11
+ * Desktop row start: start-row-{1..N}
12
+ * Row span: span-row-{1..N} (e.g. span-row-2 = span 2 rows)
13
+ * Mobile column start: m_start-col-{1..12}
14
+ * Mobile row start: m_start-row-{1..N}
15
+ * Mobile row span: m_span-row-{1..N}
12
16
  *
13
17
  * These helpers round-trip cleanly so the same class string renders identically
14
18
  * in FormFlow and the form builder, and survives storage as JSON Schema.
@@ -23,7 +27,11 @@ export const DEFAULT_ROW_SPAN = 1
23
27
  const COL_RE = /^col-(\d{1,2})\/12$/
24
28
  const COL_START_RE = /^start-col-(\d{1,2})$/
25
29
  const M_COL_RE = /^m_col-(\d{1,2})\/12$/
26
- const ROW_RE = /^(?:grid-)?span-row-(\d{1,2})$/
30
+ const ROW_START_RE = /^start-row-(\d+)$/
31
+ const ROW_RE = /^(?:grid-)?span-row-(\d+)$/
32
+ const M_COL_START_RE = /^m_start-col-(\d{1,2})$/
33
+ const M_ROW_START_RE = /^m_start-row-(\d+)$/
34
+ const M_ROW_RE = /^m_(?:grid-)?span-row-(\d+)$/
27
35
 
28
36
  // Fraction aliases (col-1/2, col-2/3, ...) map to a canonical numeric span.
29
37
  const FRACTION_TO_SPAN: Record<string, number> = {
@@ -50,6 +58,10 @@ function clampSpan(span: number): number {
50
58
  return Math.max(1, Math.min(GRID_COLS, Math.round(span)))
51
59
  }
52
60
 
61
+ function normalizeRowSpan(span: number): number {
62
+ return Math.max(1, Math.round(span))
63
+ }
64
+
53
65
  /** Desktop column span -> canonical class (e.g. 6 -> "col-6/12"). */
54
66
  export function colSpanToClass(span: number): string {
55
67
  return `col-${clampSpan(span)}/12`
@@ -67,7 +79,24 @@ export function mColSpanToClass(span: number): string {
67
79
 
68
80
  /** Row span -> canonical class (e.g. 2 -> "span-row-2"). */
69
81
  export function rowSpanToClass(span: number): string {
70
- return `span-row-${clampSpan(span)}`
82
+ return `span-row-${normalizeRowSpan(span)}`
83
+ }
84
+
85
+ /** Desktop starting row -> canonical class (e.g. 4 -> "start-row-4"). */
86
+ export function rowStartToClass(start: number): string {
87
+ return `start-row-${Math.max(1, Math.round(start))}`
88
+ }
89
+
90
+ export function mColStartToClass(start: number): string {
91
+ return `m_start-col-${clampSpan(start)}`
92
+ }
93
+
94
+ export function mRowStartToClass(start: number): string {
95
+ return `m_start-row-${Math.max(1, Math.round(start))}`
96
+ }
97
+
98
+ export function mRowSpanToClass(span: number): string {
99
+ return `m_span-row-${normalizeRowSpan(span)}`
71
100
  }
72
101
 
73
102
  // ─── class -> span ───────────────────────────────────────────────────────────
@@ -105,12 +134,49 @@ export function classToMColSpan(cls: string | undefined): number | undefined {
105
134
  return undefined
106
135
  }
107
136
 
137
+ export function classToMColStart(cls: string | undefined): number | undefined {
138
+ if (!cls) return undefined
139
+ for (const token of cls.split(/\s+/)) {
140
+ const match = token.match(M_COL_START_RE)
141
+ if (match) return clampSpan(Number(match[1]))
142
+ }
143
+ return undefined
144
+ }
145
+
108
146
  /** Parse the first row span found in a class string, if any. */
109
147
  export function classToRowSpan(cls: string | undefined): number | undefined {
110
148
  if (!cls) return undefined
111
149
  for (const token of cls.split(/\s+/)) {
112
150
  const m = token.match(ROW_RE)
113
- if (m) return clampSpan(Number(m[1]))
151
+ if (m) return normalizeRowSpan(Number(m[1]))
152
+ }
153
+ return undefined
154
+ }
155
+
156
+ /** Parse the first desktop starting row found in a class string, if any. */
157
+ export function classToRowStart(cls: string | undefined): number | undefined {
158
+ if (!cls) return undefined
159
+ for (const token of cls.split(/\s+/)) {
160
+ const match = token.match(ROW_START_RE)
161
+ if (match) return Math.max(1, Number(match[1]))
162
+ }
163
+ return undefined
164
+ }
165
+
166
+ export function classToMRowStart(cls: string | undefined): number | undefined {
167
+ if (!cls) return undefined
168
+ for (const token of cls.split(/\s+/)) {
169
+ const match = token.match(M_ROW_START_RE)
170
+ if (match) return Math.max(1, Number(match[1]))
171
+ }
172
+ return undefined
173
+ }
174
+
175
+ export function classToMRowSpan(cls: string | undefined): number | undefined {
176
+ if (!cls) return undefined
177
+ for (const token of cls.split(/\s+/)) {
178
+ const match = token.match(M_ROW_RE)
179
+ if (match) return normalizeRowSpan(Number(match[1]))
114
180
  }
115
181
  return undefined
116
182
  }
@@ -127,7 +193,11 @@ export function stripGridClasses(cls: string | undefined): string {
127
193
  !COL_RE.test(t)
128
194
  && !COL_START_RE.test(t)
129
195
  && !M_COL_RE.test(t)
196
+ && !ROW_START_RE.test(t)
130
197
  && !ROW_RE.test(t)
198
+ && !M_COL_START_RE.test(t)
199
+ && !M_ROW_START_RE.test(t)
200
+ && !M_ROW_RE.test(t)
131
201
  && !(t in FRACTION_TO_SPAN)
132
202
  && !(t in M_FRACTION_TO_SPAN),
133
203
  )
@@ -137,8 +207,12 @@ export function stripGridClasses(cls: string | undefined): string {
137
207
  export interface GridSpans {
138
208
  colSpan?: number
139
209
  colStart?: number
210
+ rowStart?: number
140
211
  rowSpan?: number
141
212
  mColSpan?: number
213
+ mColStart?: number
214
+ mRowStart?: number
215
+ mRowSpan?: number
142
216
  }
143
217
 
144
218
  /**
@@ -155,12 +229,24 @@ export function applyGridClasses(cls: string | undefined, spans: GridSpans): str
155
229
  if (spans.colStart !== undefined) {
156
230
  parts.push(colStartToClass(spans.colStart))
157
231
  }
232
+ if (spans.rowStart !== undefined) {
233
+ parts.push(rowStartToClass(spans.rowStart))
234
+ }
158
235
  if (spans.rowSpan !== undefined && spans.rowSpan !== DEFAULT_ROW_SPAN) {
159
236
  parts.push(rowSpanToClass(spans.rowSpan))
160
237
  }
161
238
  if (spans.mColSpan !== undefined && spans.mColSpan !== DEFAULT_COL_SPAN) {
162
239
  parts.push(mColSpanToClass(spans.mColSpan))
163
240
  }
241
+ if (spans.mColStart !== undefined) {
242
+ parts.push(mColStartToClass(spans.mColStart))
243
+ }
244
+ if (spans.mRowStart !== undefined) {
245
+ parts.push(mRowStartToClass(spans.mRowStart))
246
+ }
247
+ if (spans.mRowSpan !== undefined && spans.mRowSpan !== DEFAULT_ROW_SPAN) {
248
+ parts.push(mRowSpanToClass(spans.mRowSpan))
249
+ }
164
250
  return parts.join(' ')
165
251
  }
166
252
 
@@ -169,7 +255,11 @@ export function classToGridSpans(cls: string | undefined): GridSpans {
169
255
  return {
170
256
  colSpan: classToColSpan(cls),
171
257
  colStart: classToColStart(cls),
258
+ rowStart: classToRowStart(cls),
172
259
  rowSpan: classToRowSpan(cls),
173
260
  mColSpan: classToMColSpan(cls),
261
+ mColStart: classToMColStart(cls),
262
+ mRowStart: classToMRowStart(cls),
263
+ mRowSpan: classToMRowSpan(cls),
174
264
  }
175
265
  }
@@ -10,7 +10,11 @@ import {
10
10
  applyGridClasses,
11
11
  classToColSpan,
12
12
  classToColStart,
13
+ classToMColStart,
13
14
  classToMColSpan,
15
+ classToMRowStart,
16
+ classToMRowSpan,
17
+ classToRowStart,
14
18
  classToRowSpan,
15
19
  stripGridClasses,
16
20
  } from './grid-classes'
@@ -53,10 +57,18 @@ export interface FormField {
53
57
  colSpan?: number
54
58
  /** Desktop starting column (1-12). Persisted as `start-col-N`. */
55
59
  colStart?: number
56
- /** Row span (1-12, default 1). Persisted as `span-row-N`. */
60
+ /** Desktop starting row (1+). Persisted as `start-row-N`. */
61
+ rowStart?: number
62
+ /** Row span (1+, default 1). Persisted as `span-row-N`. */
57
63
  rowSpan?: number
58
64
  /** Mobile column span (1-12, default 12). Persisted as `m_col-N/12`. */
59
65
  mColSpan?: number
66
+ /** Mobile starting column (1-12). Persisted as `m_start-col-N`. */
67
+ mColStart?: number
68
+ /** Mobile starting row (1+). Persisted as `m_start-row-N`. */
69
+ mRowStart?: number
70
+ /** Mobile row span (1+). Persisted as `m_span-row-N`. */
71
+ mRowSpan?: number
60
72
  /** User-authored (non-grid) classes preserved alongside layout classes. */
61
73
  class?: string
62
74
  hideLabel?: boolean
@@ -247,8 +259,12 @@ export function schemaToFields(schema: JSONSchemaObject | undefined): FormField[
247
259
  required: requiredSet.has(key) || undefined,
248
260
  colSpan: classToColSpan(uiClass) ?? 12,
249
261
  colStart: classToColStart(uiClass),
262
+ rowStart: classToRowStart(uiClass),
250
263
  rowSpan: classToRowSpan(uiClass) ?? defaultRowSpan(type),
251
264
  mColSpan: classToMColSpan(uiClass) ?? 12,
265
+ mColStart: classToMColStart(uiClass),
266
+ mRowStart: classToMRowStart(uiClass),
267
+ mRowSpan: classToMRowSpan(uiClass),
252
268
  class: stripGridClasses(uiClass) || undefined,
253
269
  hideLabel: prop['x-hide-label'] ?? false,
254
270
  condition: prop['x-ui']?.condition || undefined,
@@ -296,7 +312,10 @@ export function schemaToFields(schema: JSONSchemaObject | undefined): FormField[
296
312
 
297
313
  // ─── Fields → Schema ─────────────────────────────────────────────────────────
298
314
 
299
- export function fieldsToSchema(fields: FormField[]): JSONSchemaObject {
315
+ export function fieldsToSchema(
316
+ fields: FormField[],
317
+ options: { freeGrid?: boolean } = {},
318
+ ): JSONSchemaObject {
300
319
  const properties: Record<string, JSONSchemaProperty> = {}
301
320
  const required: string[] = []
302
321
 
@@ -392,13 +411,21 @@ export function fieldsToSchema(fields: FormField[]): JSONSchemaObject {
392
411
  // Options
393
412
  if (field.validation?.options) prop['x-options'] = field.validation.options
394
413
 
395
- // Layout — encoded as grid utility classes inside x-ui.class, merged
396
- // with any user-authored classes. Row default is field-type dependent.
414
+ const rowSpan = field.rowSpan ?? defaultRowSpan(field.type)
415
+ // Free-grid needs every multi-row default persisted because the live
416
+ // renderer owns fixed row geometry. Flow forms preserve the prior
417
+ // behavior and omit field-type defaults.
397
418
  const layoutClass = applyGridClasses(field.class, {
398
419
  colSpan: field.colSpan,
399
420
  colStart: field.colStart,
400
- rowSpan: field.rowSpan === defaultRowSpan(field.type) ? undefined : field.rowSpan,
421
+ rowStart: field.rowStart,
422
+ rowSpan: options.freeGrid
423
+ ? (rowSpan > 1 ? rowSpan : undefined)
424
+ : (rowSpan === defaultRowSpan(field.type) ? undefined : rowSpan),
401
425
  mColSpan: field.mColSpan,
426
+ mColStart: field.mColStart,
427
+ mRowStart: field.mRowStart,
428
+ mRowSpan: field.mRowSpan,
402
429
  })
403
430
  if (layoutClass) prop['x-ui'] = { ...prop['x-ui'], class: layoutClass }
404
431
 
@@ -509,6 +509,22 @@ select {
509
509
  row-gap: 0.5rem;
510
510
  }
511
511
 
512
+ .grid-form.free-grid {
513
+ grid-auto-rows: 72px;
514
+ }
515
+
516
+ .grid-form.free-grid > * {
517
+ grid-row-start: var(--bgl-grid-row-start, auto);
518
+ grid-row-end: span var(--bgl-grid-row-span, 1);
519
+ min-height: 0;
520
+ }
521
+
522
+ .grid-form.free-grid > * > .bagel-input {
523
+ height: 100%;
524
+ min-height: 0;
525
+ margin-bottom: 0;
526
+ }
527
+
512
528
  .grid-form.thin .bagel-input,
513
529
  .grid-form.dense .bagel-input {
514
530
  margin-bottom: 0;
@@ -620,6 +636,11 @@ select {
620
636
  }
621
637
 
622
638
  @media screen and (max-width: 910px) {
639
+ .grid-form.free-grid > * {
640
+ grid-row-start: var(--bgl-mobile-grid-row-start, var(--bgl-grid-row-start, auto));
641
+ grid-row-end: span var(--bgl-mobile-grid-row-span, var(--bgl-grid-row-span, 1));
642
+ }
643
+
623
644
  .bagel-input.wider input {
624
645
  min-width: 120px;
625
646
  }
@@ -717,4 +738,17 @@ select {
717
738
  .m_col-12\/12 {
718
739
  grid-column: span 12 !important;
719
740
  }
741
+
742
+ .grid-form.free-grid .m_start-col-1 { grid-column-start: 1 !important; }
743
+ .grid-form.free-grid .m_start-col-2 { grid-column-start: 2 !important; }
744
+ .grid-form.free-grid .m_start-col-3 { grid-column-start: 3 !important; }
745
+ .grid-form.free-grid .m_start-col-4 { grid-column-start: 4 !important; }
746
+ .grid-form.free-grid .m_start-col-5 { grid-column-start: 5 !important; }
747
+ .grid-form.free-grid .m_start-col-6 { grid-column-start: 6 !important; }
748
+ .grid-form.free-grid .m_start-col-7 { grid-column-start: 7 !important; }
749
+ .grid-form.free-grid .m_start-col-8 { grid-column-start: 8 !important; }
750
+ .grid-form.free-grid .m_start-col-9 { grid-column-start: 9 !important; }
751
+ .grid-form.free-grid .m_start-col-10 { grid-column-start: 10 !important; }
752
+ .grid-form.free-grid .m_start-col-11 { grid-column-start: 11 !important; }
753
+ .grid-form.free-grid .m_start-col-12 { grid-column-start: 12 !important; }
720
754
  }