@structured-field/widget-editor 1.3.0 → 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.3.0",
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": {
@@ -29,7 +33,7 @@
29
33
  "dev": "rollup -c -w",
30
34
  "test": "vitest run",
31
35
  "test:watch": "vitest",
32
- "test:serve": "vite --open"
36
+ "playground": "vite --open"
33
37
  },
34
38
  "keywords": [
35
39
  "json-schema",
@@ -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: () => ({}) },
@@ -72,6 +82,13 @@ export default {
72
82
  if (!hasConditionals(this.schema)) return this.schema;
73
83
  return applyConditionals(this.schema, this.modelValue || {}, this.form?.resolveSchema);
74
84
  },
85
+ cells() {
86
+ return layoutCells(this.effectiveSchema.properties, {
87
+ resolveSchema: this.form?.resolveSchema,
88
+ customEditors: this.customEditors(),
89
+ basePath: this.path,
90
+ });
91
+ },
75
92
  summary() {
76
93
  const val = this.modelValue || {};
77
94
  const parts = [];
@@ -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 -->
@@ -36,8 +36,7 @@ import RelationEditor from './RelationEditor.vue';
36
36
  import JsonEditor from './JsonEditor.vue';
37
37
  import WebComponentWrapper from './WebComponentWrapper.vue';
38
38
  import { isChoiceOneOf } from '../utils';
39
-
40
- const MAX_DEPTH = 12;
39
+ import { MAX_DEPTH } from '../layout';
41
40
 
42
41
  export default {
43
42
  name: 'SchemaEditor',
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ export { default as RelationEditor } from './editors/RelationEditor.vue';
16
16
  export { default as WebComponentWrapper } from './editors/WebComponentWrapper.vue';
17
17
  export { BaseEditorElement } from './BaseEditorElement.js';
18
18
  export { applyConditionals, matchesSchema, hasConditionals } from './conditionals.js';
19
+ export { fieldSize, layoutCells, normalizeLayoutHint } from './layout.js';
19
20
 
20
21
  import { defineCustomElement } from 'vue';
21
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';