@structured-field/widget-editor 1.2.2 → 1.4.0

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,8 +1,12 @@
1
1
  {
2
2
  "name": "@structured-field/widget-editor",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "A lightweight JSON Schema form builder with support for relation fields (ForeignKey, QuerySet) and autocomplete search.",
5
5
  "type": "module",
6
+ "packageManager": "pnpm@10.11.1",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
6
10
  "main": "dist/structured-widget-editor.js",
7
11
  "module": "dist/structured-widget-editor.esm.js",
8
12
  "repository": {
@@ -27,7 +31,9 @@
27
31
  "scripts": {
28
32
  "build": "rollup -c && node scripts/bundle-iife.mjs",
29
33
  "dev": "rollup -c -w",
30
- "test:serve": "vite --open"
34
+ "test": "vitest run",
35
+ "test:watch": "vitest",
36
+ "playground": "vite --open"
31
37
  },
32
38
  "keywords": [
33
39
  "json-schema",
@@ -13,7 +13,7 @@
13
13
 
14
14
  <script>
15
15
  import SchemaEditor from './editors/SchemaEditor.vue';
16
- import { deepClone } from './utils';
16
+ import { deepClone, isChoiceOneOf } from './utils';
17
17
  import { applyConditionals, hasConditionals } from './conditionals';
18
18
 
19
19
  export default {
@@ -89,29 +89,36 @@ export default {
89
89
  const hasNull = schema.anyOf.some(s => s.type === 'null');
90
90
  if (hasNull && nonNull.length === 1) {
91
91
  const resolved = this.resolveSchema(nonNull[0]);
92
- return {
93
- ...resolved,
94
- _nullable: true,
95
- title: schema.title || resolved.title,
96
- default: 'default' in schema ? schema.default : null,
97
- };
92
+ // Carry ALL sibling keys through the nullable collapse: pydantic
93
+ // emits json_schema_extra (placeholder, minLength/maxLength,
94
+ // minimum/maximum, format, choice oneOf, ...) as SIBLINGS of
95
+ // anyOf on Optional fields. Outer keys override the inner branch.
96
+ const { anyOf, oneOf, ...rest } = schema;
97
+ const out = { ...resolved, ...rest, _nullable: true };
98
+ if (!('default' in schema)) out.default = null;
99
+ if (isChoiceOneOf(oneOf)) out.oneOf = oneOf;
100
+ return out;
98
101
  }
99
102
  if (nonNull.length >= 1) return this.resolveSchema(nonNull[0]);
100
103
  }
101
104
 
102
105
  if (schema.oneOf && schema.discriminator) return schema;
103
106
 
107
+ // A oneOf of {const, title} value options is a choice list (select),
108
+ // not alternative sub-schemas: keep it intact for SelectEditor instead
109
+ // of collapsing to the first member (which routed to HiddenEditor and
110
+ // overwrote stored values on mount).
111
+ if (isChoiceOneOf(schema.oneOf)) return schema;
112
+
104
113
  if (schema.oneOf) {
105
114
  const nonNull = schema.oneOf.filter(s => s.type !== 'null');
106
115
  const hasNull = schema.oneOf.some(s => s.type === 'null');
107
116
  if (hasNull && nonNull.length === 1) {
108
117
  const resolved = this.resolveSchema(nonNull[0]);
109
- return {
110
- ...resolved,
111
- _nullable: true,
112
- title: schema.title || resolved.title,
113
- default: 'default' in schema ? schema.default : null,
114
- };
118
+ const { anyOf, oneOf, ...rest } = schema;
119
+ const out = { ...resolved, ...rest, _nullable: true };
120
+ if (!('default' in schema)) out.default = null;
121
+ return out;
115
122
  }
116
123
  if (nonNull.length >= 1) return this.resolveSchema(nonNull[0]);
117
124
  }
@@ -0,0 +1,95 @@
1
+ <template>
2
+ <div class="sf-field" :class="{ errors: fieldErrors.length }">
3
+ <span class="sf-label" :class="{ required: isRequired }">
4
+ {{ title }}
5
+ <span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
6
+ </span>
7
+ <div :class="isNullable ? 'sf-input-row' : null">
8
+ <input
9
+ :type="inputType"
10
+ class="sf-input"
11
+ :value="displayValue"
12
+ :placeholder="isNullValue ? 'null' : (schema.placeholder || '')"
13
+ :min="schema.minimum || schema.formatMinimum || null"
14
+ :max="schema.maximum || schema.formatMaximum || null"
15
+ @input="onInput"
16
+ />
17
+ <button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">&#x2715;</button>
18
+ </div>
19
+ <ul v-if="fieldErrors.length" class="errorlist">
20
+ <li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
21
+ </ul>
22
+ </div>
23
+ </template>
24
+
25
+ <script>
26
+ export default {
27
+ name: 'DateEditor',
28
+ props: {
29
+ schema: { type: Object, required: true },
30
+ modelValue: { default: '' },
31
+ path: { type: Array, default: () => [] },
32
+ form: { type: Object, default: null },
33
+ },
34
+ emits: ['update:modelValue'],
35
+ computed: {
36
+ isDateTime() {
37
+ return this.schema.format === 'date-time';
38
+ },
39
+ inputType() {
40
+ return this.isDateTime ? 'datetime-local' : 'date';
41
+ },
42
+ displayValue() {
43
+ if (this.isNullValue) return '';
44
+ const v = String(this.modelValue);
45
+ if (this.isDateTime) {
46
+ // Accept ISO 8601 strings like "2026-04-09T10:30:00[.sss][Z|+00:00]"
47
+ // datetime-local expects "YYYY-MM-DDTHH:mm" (or with seconds).
48
+ const match = v.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2}(?:\.\d+)?))?/);
49
+ if (match) {
50
+ return match[3] ? `${match[1]}T${match[2]}:${match[3]}` : `${match[1]}T${match[2]}`;
51
+ }
52
+ return v;
53
+ }
54
+ // date: expect "YYYY-MM-DD"
55
+ return v.slice(0, 10);
56
+ },
57
+ title() {
58
+ return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
59
+ },
60
+ isRequired() {
61
+ if (this.path.length < 2 || !this.form) return false;
62
+ const parentPath = this.path.slice(0, -1);
63
+ const fieldName = this.path[this.path.length - 1];
64
+ const parentSchema = this.form.getSchemaAtPath(parentPath);
65
+ return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
66
+ },
67
+ isNullable() {
68
+ return !!this.schema._nullable;
69
+ },
70
+ isNullValue() {
71
+ return this.modelValue === null || this.modelValue === undefined;
72
+ },
73
+ fieldErrors() {
74
+ if (!this.form || !this.form.getErrorsForPath) return [];
75
+ return this.form.getErrorsForPath(this.path);
76
+ },
77
+ },
78
+ methods: {
79
+ onInput(e) {
80
+ const val = e.target.value;
81
+ if (val === '') {
82
+ this.$emit('update:modelValue', this.isNullable ? null : '');
83
+ return;
84
+ }
85
+ // Emit ISO-compatible string. Pydantic accepts both "YYYY-MM-DD"
86
+ // and "YYYY-MM-DDTHH:mm[:ss]" for date / datetime fields.
87
+ this.$emit('update:modelValue', val);
88
+ },
89
+ humanize(str) {
90
+ if (!str) return '';
91
+ return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
92
+ },
93
+ },
94
+ };
95
+ </script>
@@ -1,15 +1,18 @@
1
1
  <template>
2
2
  <div v-if="isRoot" class="sf-object sf-object-root">
3
3
  <div class="sf-object-fields">
4
- <SchemaEditor
5
- v-for="(propSchema, key) in (effectiveSchema.properties || {})"
6
- :key="key"
7
- :schema="form.resolveSchema(propSchema)"
8
- :model-value="(modelValue || {})[key]"
9
- :path="[...path, key]"
10
- :form="form"
11
- @update:model-value="onChildChange(key, $event)"
12
- />
4
+ <template v-for="cell in cells" :key="cell.key">
5
+ <div v-if="cell.breakBefore" class="sf-flow-break" aria-hidden="true"></div>
6
+ <div :class="cell.classes">
7
+ <SchemaEditor
8
+ :schema="cell.schema"
9
+ :model-value="(modelValue || {})[cell.key]"
10
+ :path="[...path, cell.key]"
11
+ :form="form"
12
+ @update:model-value="onChildChange(cell.key, $event)"
13
+ />
14
+ </div>
15
+ </template>
13
16
  </div>
14
17
  </div>
15
18
  <fieldset v-else class="sf-object" :class="{ 'sf-object-collapsed': collapsed }">
@@ -21,15 +24,18 @@
21
24
  <span v-if="collapsed && summary" class="sf-object-summary">{{ summary }}</span>
22
25
  </legend>
23
26
  <div v-show="!collapsed" class="sf-object-fields">
24
- <SchemaEditor
25
- v-for="(propSchema, key) in (effectiveSchema.properties || {})"
26
- :key="key"
27
- :schema="form.resolveSchema(propSchema)"
28
- :model-value="(modelValue || {})[key]"
29
- :path="[...path, key]"
30
- :form="form"
31
- @update:model-value="onChildChange(key, $event)"
32
- />
27
+ <template v-for="cell in cells" :key="cell.key">
28
+ <div v-if="cell.breakBefore" class="sf-flow-break" aria-hidden="true"></div>
29
+ <div :class="cell.classes">
30
+ <SchemaEditor
31
+ :schema="cell.schema"
32
+ :model-value="(modelValue || {})[cell.key]"
33
+ :path="[...path, cell.key]"
34
+ :form="form"
35
+ @update:model-value="onChildChange(cell.key, $event)"
36
+ />
37
+ </div>
38
+ </template>
33
39
  </div>
34
40
  </fieldset>
35
41
  </template>
@@ -38,6 +44,7 @@
38
44
  import SchemaEditor from './SchemaEditor.vue';
39
45
  import SfIcon from './SfIcon.vue';
40
46
  import { applyConditionals, hasConditionals } from '../conditionals';
47
+ import { layoutCells } from '../layout';
41
48
 
42
49
  export default {
43
50
  name: 'ObjectEditor',
@@ -46,6 +53,9 @@ export default {
46
53
  this.$options.components.SchemaEditor = SchemaEditor;
47
54
  this.$options.components.SfIcon = SfIcon;
48
55
  },
56
+ inject: {
57
+ customEditors: { default: () => () => [] },
58
+ },
49
59
  props: {
50
60
  schema: { type: Object, required: true },
51
61
  modelValue: { default: () => ({}) },
@@ -56,6 +66,9 @@ export default {
56
66
  data() {
57
67
  return {
58
68
  collapsed: false,
69
+ // Values pruned when a conditional rule deactivated their field,
70
+ // kept so toggling the controller back restores what the user typed.
71
+ prunedStash: {},
59
72
  };
60
73
  },
61
74
  computed: {
@@ -69,6 +82,13 @@ export default {
69
82
  if (!hasConditionals(this.schema)) return this.schema;
70
83
  return applyConditionals(this.schema, this.modelValue || {}, this.form?.resolveSchema);
71
84
  },
85
+ cells() {
86
+ return layoutCells(this.effectiveSchema.properties, {
87
+ resolveSchema: this.form?.resolveSchema,
88
+ customEditors: this.customEditors(),
89
+ basePath: this.path,
90
+ });
91
+ },
72
92
  summary() {
73
93
  const val = this.modelValue || {};
74
94
  const parts = [];
@@ -103,10 +123,21 @@ export default {
103
123
  const allowed = new Set(Object.keys(effective.properties || {}));
104
124
  let changed = false;
105
125
  const out = {};
126
+ // restore stashed values for fields a rule just re-activated
127
+ for (const k of allowed) {
128
+ if (!(k in value) && k in this.prunedStash) {
129
+ out[k] = this.prunedStash[k];
130
+ delete this.prunedStash[k];
131
+ changed = true;
132
+ }
133
+ }
106
134
  for (const k of Object.keys(value)) {
107
135
  if (allowed.has(k)) {
108
136
  out[k] = value[k];
109
137
  } else {
138
+ // pruned from the emitted value (documented behavior), but kept
139
+ // locally so a controller toggle round-trip is not destructive
140
+ this.prunedStash[k] = value[k];
110
141
  changed = true;
111
142
  }
112
143
  }
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="sf-field sf-relation" :class="{ errors: fieldErrors.length }" ref="root">
2
+ <div class="sf-field sf-relation" :class="{ errors: fieldErrors.length, 'sf-relation-multiple': isMultiple, 'sf-relation-open': dropdownVisible }" ref="root">
3
3
  <span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
4
4
  <div class="sf-relation-wrapper">
5
5
  <!-- Selected items -->
@@ -25,6 +25,7 @@
25
25
  import StringEditor from './StringEditor.vue';
26
26
  import NumberEditor from './NumberEditor.vue';
27
27
  import BooleanEditor from './BooleanEditor.vue';
28
+ import DateEditor from './DateEditor.vue';
28
29
  import SelectEditor from './SelectEditor.vue';
29
30
  import HiddenEditor from './HiddenEditor.vue';
30
31
  import ObjectEditor from './ObjectEditor.vue';
@@ -34,8 +35,8 @@ import UnionEditor from './UnionEditor.vue';
34
35
  import RelationEditor from './RelationEditor.vue';
35
36
  import JsonEditor from './JsonEditor.vue';
36
37
  import WebComponentWrapper from './WebComponentWrapper.vue';
37
-
38
- const MAX_DEPTH = 12;
38
+ import { isChoiceOneOf } from '../utils';
39
+ import { MAX_DEPTH } from '../layout';
39
40
 
40
41
  export default {
41
42
  name: 'SchemaEditor',
@@ -43,6 +44,7 @@ export default {
43
44
  StringEditor,
44
45
  NumberEditor,
45
46
  BooleanEditor,
47
+ DateEditor,
46
48
  SelectEditor,
47
49
  HiddenEditor,
48
50
  ObjectEditor,
@@ -92,6 +94,9 @@ export default {
92
94
 
93
95
  if (schema.type === 'relation') return 'RelationEditor';
94
96
  if (schema.oneOf && schema.discriminator) return 'UnionEditor';
97
+ // Choice-list oneOf ({const, title} options) renders as a select —
98
+ // must be checked before the 'const' HiddenEditor routing.
99
+ if (isChoiceOneOf(schema.oneOf)) return 'SelectEditor';
95
100
  if ('const' in schema) return 'HiddenEditor';
96
101
  if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'HiddenEditor';
97
102
  if (schema._nullable && (schema.type === 'object' || schema.type === 'array')) return 'NullableEditor';
@@ -101,6 +106,7 @@ export default {
101
106
  if (schema.enum) return 'SelectEditor';
102
107
  if (schema.type === 'boolean') return 'BooleanEditor';
103
108
  if (schema.type === 'number' || schema.type === 'integer') return 'NumberEditor';
109
+ if (schema.type === 'string' && (schema.format === 'date' || schema.format === 'date-time')) return 'DateEditor';
104
110
 
105
111
  return 'StringEditor';
106
112
  },
@@ -7,12 +7,12 @@
7
7
  <div :class="isNullable ? 'sf-input-row' : null">
8
8
  <select
9
9
  class="sf-input sf-select"
10
- :value="isNullValue ? '' : (modelValue != null ? String(modelValue) : '')"
11
- @change="$emit('update:modelValue', $event.target.value)"
10
+ :value="selectedIndex === -1 ? '' : String(selectedIndex)"
11
+ @change="onChange($event.target.value)"
12
12
  >
13
- <option v-if="isNullable && isNullValue" value="" disabled selected>null</option>
14
- <option v-for="opt in (schema.enum || [])" :key="opt" :value="String(opt)">
15
- {{ opt }}
13
+ <option v-if="selectedIndex === -1" value="" disabled selected>{{ isNullValue ? 'null' : '' }}</option>
14
+ <option v-for="(opt, i) in options" :key="i" :value="String(i)">
15
+ {{ opt.label }}
16
16
  </option>
17
17
  </select>
18
18
  <button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">&#x2715;</button>
@@ -24,6 +24,8 @@
24
24
  </template>
25
25
 
26
26
  <script>
27
+ import { isChoiceOneOf } from '../utils';
28
+
27
29
  export default {
28
30
  name: 'SelectEditor',
29
31
  props: {
@@ -34,6 +36,20 @@ export default {
34
36
  },
35
37
  emits: ['update:modelValue'],
36
38
  computed: {
39
+ options() {
40
+ // Two source shapes: a choice-list oneOf ({const, title} pairs, e.g.
41
+ // metaobjects' select kind — labels preserved) or a plain enum.
42
+ if (isChoiceOneOf(this.schema.oneOf)) {
43
+ return this.schema.oneOf.map((o) => ({
44
+ value: o.const,
45
+ label: o.title != null ? o.title : String(o.const),
46
+ }));
47
+ }
48
+ return (this.schema.enum || []).map((v) => ({ value: v, label: String(v) }));
49
+ },
50
+ selectedIndex() {
51
+ return this.options.findIndex((o) => o.value === this.modelValue);
52
+ },
37
53
  title() {
38
54
  return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
39
55
  },
@@ -56,6 +72,13 @@ export default {
56
72
  },
57
73
  },
58
74
  methods: {
75
+ onChange(indexStr) {
76
+ const opt = this.options[Number(indexStr)];
77
+ // Emit the ORIGINAL option value (options are addressed by index in
78
+ // the DOM), so integer/boolean enums keep their native type instead
79
+ // of being stringified by the <select> element.
80
+ if (opt !== undefined) this.$emit('update:modelValue', opt.value);
81
+ },
59
82
  humanize(str) {
60
83
  if (!str) return '';
61
84
  return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ export { default as SchemaEditor } from './editors/SchemaEditor.vue';
5
5
  export { default as StringEditor } from './editors/StringEditor.vue';
6
6
  export { default as NumberEditor } from './editors/NumberEditor.vue';
7
7
  export { default as BooleanEditor } from './editors/BooleanEditor.vue';
8
+ export { default as DateEditor } from './editors/DateEditor.vue';
8
9
  export { default as SelectEditor } from './editors/SelectEditor.vue';
9
10
  export { default as HiddenEditor } from './editors/HiddenEditor.vue';
10
11
  export { default as ObjectEditor } from './editors/ObjectEditor.vue';
@@ -15,6 +16,7 @@ export { default as RelationEditor } from './editors/RelationEditor.vue';
15
16
  export { default as WebComponentWrapper } from './editors/WebComponentWrapper.vue';
16
17
  export { BaseEditorElement } from './BaseEditorElement.js';
17
18
  export { applyConditionals, matchesSchema, hasConditionals } from './conditionals.js';
19
+ export { fieldSize, layoutCells, normalizeLayoutHint } from './layout.js';
18
20
 
19
21
  import { defineCustomElement } from 'vue';
20
22
  import SchemaFormComponent from './SchemaForm.vue';
package/src/layout.js ADDED
@@ -0,0 +1,116 @@
1
+ import { isChoiceOneOf } from './utils';
2
+
3
+ // Field-size classification for the multicolumn flow layout.
4
+ // Sizes are flex-basis tokens (see scss/components/layout.scss), not column
5
+ // counts: columns emerge from how many cells fit the container's width.
6
+ // Invariant: visual order === DOM order === tab order. Never reorder cells.
7
+
8
+ const SIZES = ['xs', 'sm', 'md', 'lg', 'full'];
9
+ const BREAKS = ['before', 'after', 'both'];
10
+
11
+ // Schema-author hint: `layout: 'sm'` or `layout: { size: 'sm', break: 'before' }`.
12
+ // Invalid hints are silently ignored so a typo degrades to the heuristic.
13
+ export function normalizeLayoutHint(layout) {
14
+ if (typeof layout === 'string') {
15
+ return { size: SIZES.includes(layout) ? layout : null, break: null };
16
+ }
17
+ if (layout && typeof layout === 'object' && !Array.isArray(layout)) {
18
+ return {
19
+ size: SIZES.includes(layout.size) ? layout.size : null,
20
+ break: BREAKS.includes(layout.break) ? layout.break : null,
21
+ };
22
+ }
23
+ return { size: null, break: null };
24
+ }
25
+
26
+ // Nullable scalars carry the inline null-clear button (~30px of chrome), and
27
+ // datetime-local needs its full width — one tier of slack keeps them usable.
28
+ const NULLABLE_BUMP = { xs: 'sm', sm: 'md' };
29
+
30
+ function bumpNullable(size, schema) {
31
+ return schema._nullable ? (NULLABLE_BUMP[size] || size) : size;
32
+ }
33
+
34
+ function choiceSize(labels, schema) {
35
+ const compact = labels.length <= 8 && labels.every((l) => String(l ?? '').length <= 12);
36
+ return bumpNullable(compact ? 'sm' : 'md', schema);
37
+ }
38
+
39
+ // Shared with SchemaEditor: past this depth everything renders StringEditor.
40
+ export const MAX_DEPTH = 12;
41
+
42
+ // Intrinsic size of a RESOLVED schema node.
43
+ // Returns 'xs' | 'sm' | 'md' | 'lg' | 'full' | 'hidden'.
44
+ // Hidden routing (const / single-string-enum) is authoritative: a size hint
45
+ // on a field that renders HiddenEditor would only produce an empty cell.
46
+ export function fieldSize(schema) {
47
+ if (!schema || typeof schema !== 'object') return 'full';
48
+ const intrinsic = intrinsicSize(schema);
49
+ if (intrinsic === 'hidden') return 'hidden';
50
+ return normalizeLayoutHint(schema.layout).size || intrinsic;
51
+ }
52
+
53
+ // The branch order mirrors SchemaEditor.editorComponent — keep them in sync.
54
+ function intrinsicSize(schema) {
55
+ if (schema.type === 'relation') return schema.multiple ? 'lg' : 'md';
56
+ if (schema.oneOf && schema.discriminator) return 'full';
57
+ if (isChoiceOneOf(schema.oneOf)) {
58
+ return choiceSize(schema.oneOf.map((o) => o.title ?? o.const), schema);
59
+ }
60
+ if ('const' in schema) return 'hidden';
61
+ if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'hidden';
62
+ // Covers ObjectEditor, JsonEditor, ArrayEditor and NullableEditor containers.
63
+ if (schema.type === 'object' || schema.type === 'array') return 'full';
64
+ if (schema.enum) return choiceSize(schema.enum, schema);
65
+ if (schema.type === 'boolean') return bumpNullable('xs', schema);
66
+ if (schema.type === 'number' || schema.type === 'integer') return bumpNullable('xs', schema);
67
+ if (schema.type === 'string') {
68
+ if (schema.format === 'date') return bumpNullable('sm', schema);
69
+ if (schema.format === 'date-time') return bumpNullable('md', schema);
70
+ // Mirrors StringEditor.isLong (textarea rendering).
71
+ if (schema.format === 'textarea' || schema.maxLength > 255) return 'full';
72
+ if (schema.maxLength > 0 && schema.maxLength <= 40) return bumpNullable('sm', schema);
73
+ return 'md';
74
+ }
75
+ return 'full';
76
+ }
77
+
78
+ // Builds the cell list for an object's properties: resolved schema, wrapper
79
+ // classes and row-break flags. `break: 'after'` marks the NEXT visible field;
80
+ // hidden fields neither consume nor emit a pending break.
81
+ // Custom-editor matches default to 'full' (we can't predict their rendering)
82
+ // unless the schema hint or the override's own `layout` says otherwise.
83
+ export function layoutCells(properties, { resolveSchema, customEditors = [], basePath = [] } = {}) {
84
+ const cells = [];
85
+ let pendingBreak = false;
86
+ // Mirrors SchemaEditor: the depth guard runs before custom-editor overrides,
87
+ // and past it every field (const included) renders a visible StringEditor.
88
+ const pastMaxDepth = basePath.length + 1 > MAX_DEPTH;
89
+ for (const [key, raw] of Object.entries(properties || {})) {
90
+ const schema = resolveSchema ? resolveSchema(raw) : raw;
91
+ const hint = normalizeLayoutHint(schema.layout);
92
+ const override = !pastMaxDepth
93
+ && customEditors.find((o) => o.match && o.match(schema, [...basePath, key]));
94
+ let size;
95
+ if (pastMaxDepth) {
96
+ size = 'md';
97
+ } else if (override) {
98
+ size = hint.size || normalizeLayoutHint(override.layout).size || 'full';
99
+ } else {
100
+ size = fieldSize(schema);
101
+ }
102
+ const classes = ['sf-cell', `sf-cell-${size}`];
103
+ // Only the plain-boolean shape reaches the label-less BooleanEditor;
104
+ // boolean enums / choice oneOfs render SelectEditor (which has a label).
105
+ const isCheckbox = schema.type === 'boolean' && !override && !pastMaxDepth
106
+ && !schema.enum && !schema.oneOf && !('const' in schema);
107
+ let breakBefore = false;
108
+ if (size !== 'hidden') {
109
+ if (isCheckbox) classes.push('sf-cell-bool');
110
+ breakBefore = pendingBreak || hint.break === 'before' || hint.break === 'both';
111
+ pendingBreak = hint.break === 'after' || hint.break === 'both';
112
+ }
113
+ cells.push({ key, schema, classes: classes.join(' '), breakBefore });
114
+ }
115
+ return cells;
116
+ }
@@ -0,0 +1,76 @@
1
+ // Multicolumn flow layout for object fields.
2
+ //
3
+ // Model: each .sf-object-fields is a wrapping flex row; cells carry a
4
+ // flex-basis token per field kind (set in src/layout.js) and grow to share
5
+ // the line. Columns emerge from the available width at every nesting level —
6
+ // no media/container queries, no containment (container-type would create
7
+ // stacking contexts under the relation dropdown and collapse the widget's
8
+ // intrinsic width inside Django tabular-inline table cells).
9
+ //
10
+ // Invariant: visual order === DOM order === tab order. No `order`, ever.
11
+ //
12
+ // Gate: @supports (inset: 0) matches the flex-`gap` era (Chrome 87+,
13
+ // FF 66+, Safari 14.5+). Do not test `gap` itself — it false-positives on
14
+ // browsers that only support grid gap. Ungated browsers keep the original
15
+ // single-column flex layout from editors.scss.
16
+ .structured-field-editor {
17
+
18
+ .sf-cell {
19
+ // Required guard: without it a long relation tag or wide intrinsic
20
+ // content makes the flex item refuse to shrink and blows up the row.
21
+ min-width: 0;
22
+ }
23
+
24
+ .sf-cell-hidden {
25
+ display: none;
26
+ }
27
+
28
+ @supports (inset: 0) {
29
+ .sf-object-fields {
30
+ flex-flow: row wrap;
31
+ column-gap: 16px;
32
+ align-items: flex-start;
33
+ }
34
+
35
+ // Vertical rhythm stays on the editors' own margin-bottom (as in the
36
+ // single-column layout), so the zero-height row break adds no space.
37
+ .sf-flow-break {
38
+ flex-basis: 100%;
39
+ height: 0;
40
+ }
41
+
42
+ .sf-cell-xs { flex: 1 1 var(--sf-basis-xs, 8rem); }
43
+ .sf-cell-sm { flex: 1 1 var(--sf-basis-sm, 12rem); }
44
+ .sf-cell-md { flex: 1 1 var(--sf-basis-md, 18rem); }
45
+ .sf-cell-lg { flex: 1 1 var(--sf-basis-lg, 26rem); }
46
+ .sf-cell-full { flex: 1 1 100%; }
47
+
48
+ // Checkbox fields have no label above: equalize the bottom margin and
49
+ // anchor them to the line's bottom so the checkbox row sits on the same
50
+ // visual line as its neighbors' inputs.
51
+ .sf-cell > .sf-field-boolean {
52
+ margin-bottom: 12px;
53
+ }
54
+
55
+ .sf-cell-bool {
56
+ align-self: flex-end;
57
+
58
+ .sf-boolean-row {
59
+ min-height: var(--sf-control-height, 30px);
60
+ }
61
+ }
62
+
63
+ // While validation errors are shown, flex-end would sink checkboxes to
64
+ // the bottom of the grown line; anchor them to the top instead with a
65
+ // synthesized label row (one .sf-label line + its 4px margin).
66
+ @supports selector(:has(*)) {
67
+ .sf-object-fields:has(.errorlist) > .sf-cell-bool {
68
+ align-self: flex-start;
69
+
70
+ > .sf-field-boolean {
71
+ padding-top: calc(0.8125rem * 1.4 + 4px);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
@@ -1,3 +1,4 @@
1
1
  @use './components/form';
2
2
  @use './components/editors';
3
3
  @use './components/relation';
4
+ @use './components/layout';
package/src/utils.js CHANGED
@@ -16,6 +16,24 @@ export function deepClone(obj) {
16
16
  return clone;
17
17
  }
18
18
 
19
+ export function isChoiceOneOf(list) {
20
+ // A "choice list" oneOf: every member is a {const, title?} value option
21
+ // (the shape emitted for enumerated choices, e.g. metaobjects' select
22
+ // kind), as opposed to a oneOf of alternative sub-schemas.
23
+ return (
24
+ Array.isArray(list) &&
25
+ list.length > 0 &&
26
+ list.every(
27
+ (m) =>
28
+ m &&
29
+ typeof m === 'object' &&
30
+ 'const' in m &&
31
+ !('properties' in m) &&
32
+ m.type !== 'null'
33
+ )
34
+ );
35
+ }
36
+
19
37
  export function getDefaultForSchema(schema) {
20
38
  if ('default' in schema) return deepClone(schema.default);
21
39
  if (schema.type === 'object') {