@structured-field/widget-editor 1.0.3 → 1.1.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,10 +1,14 @@
1
1
  {
2
2
  "name": "@structured-field/widget-editor",
3
- "version": "1.0.3",
3
+ "version": "1.1.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
6
  "main": "dist/structured-widget-editor.js",
7
7
  "module": "dist/structured-widget-editor.esm.js",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/bnznamco/structured-widget-editor"
11
+ },
8
12
  "exports": {
9
13
  ".": {
10
14
  "import": "./dist/structured-widget-editor.esm.js",
@@ -4,27 +4,45 @@
4
4
  <span class="sf-label">{{ title }}</span>
5
5
  <span class="sf-array-count">{{ items.length }}</span>
6
6
  <button type="button" class="sf-btn sf-btn-add" @click="addItem()">
7
- <i class="fas fa-plus"></i> Add
7
+ <SfIcon name="plus" /> Add
8
8
  </button>
9
+ <span v-if="items.length" class="sf-array-collapse-toggle" :title="allCollapsed ? 'Expand all' : 'Collapse all'" @click="toggleCollapseAll">
10
+ <SfIcon :name="allCollapsed ? 'chevron-down' : 'chevron-up'" />
11
+ </span>
9
12
  </div>
10
13
  <div class="sf-array-items">
11
- <div v-for="(item, index) in items" :key="item._key" class="sf-array-item">
14
+ <div
15
+ v-for="(item, index) in items"
16
+ :key="item._key"
17
+ class="sf-array-item"
18
+ :class="{ 'sf-drag-over': dragOverIndex === index, 'sf-dragging': dragSourceIndex === index }"
19
+ draggable="true"
20
+ @dragstart="onDragStart(index, $event)"
21
+ @dragover.prevent="onDragOver(index)"
22
+ @dragleave="onDragLeave(index)"
23
+ @drop.prevent="onDrop(index)"
24
+ @dragend="onDragEnd"
25
+ >
12
26
  <div class="sf-array-item-header">
13
- <span class="sf-array-item-index">#{{ index + 1 }}</span>
27
+ <div class="sf-array-item-left">
28
+ <span class="sf-drag-handle" title="Drag to reorder"><SfIcon name="grip" /></span>
29
+ <span class="sf-array-item-index">#{{ index + 1 }}</span>
30
+ </div>
14
31
  <div class="sf-array-item-actions">
15
32
  <button v-if="index > 0" type="button" class="sf-btn sf-btn-sm" @click="moveItem(index, -1)">
16
- <i class="fas fa-arrow-up"></i>
33
+ <SfIcon name="arrow-up" />
17
34
  </button>
18
35
  <button type="button" class="sf-btn sf-btn-sm" @click="moveItem(index, 1)">
19
- <i class="fas fa-arrow-down"></i>
36
+ <SfIcon name="arrow-down" />
20
37
  </button>
21
38
  <button type="button" class="sf-btn sf-btn-sm sf-btn-danger" @click="removeItem(index)">
22
- <i class="fas fa-times"></i>
39
+ <SfIcon name="times" />
23
40
  </button>
24
41
  </div>
25
42
  </div>
26
- <div class="sf-array-item-body">
43
+ <div class="sf-array-item-body" @dragover.prevent @drop.prevent="onDrop(index)">
27
44
  <SchemaEditor
45
+ ref="itemEditors"
28
46
  :schema="itemSchema"
29
47
  :model-value="item.value"
30
48
  :path="[...path, String(index)]"
@@ -42,6 +60,7 @@
42
60
 
43
61
  <script>
44
62
  import { getDefaultForSchema } from '../utils';
63
+ import SfIcon from './SfIcon.vue';
45
64
 
46
65
  let keyCounter = 0;
47
66
 
@@ -52,6 +71,7 @@ export default {
52
71
  beforeCreate() {
53
72
  if (!this.$options.components) this.$options.components = {};
54
73
  this.$options.components.SchemaEditor = SchemaEditor;
74
+ this.$options.components.SfIcon = SfIcon;
55
75
  },
56
76
  props: {
57
77
  schema: { type: Object, required: true },
@@ -64,6 +84,9 @@ export default {
64
84
  const arr = Array.isArray(this.modelValue) ? this.modelValue : [];
65
85
  return {
66
86
  items: arr.map(v => ({ _key: keyCounter++, value: v })),
87
+ dragSourceIndex: null,
88
+ dragOverIndex: null,
89
+ allCollapsed: false,
67
90
  };
68
91
  },
69
92
  computed: {
@@ -104,6 +127,44 @@ export default {
104
127
  this.items[index].value = value;
105
128
  this.emitValue();
106
129
  },
130
+ onDragStart(index, event) {
131
+ this.dragSourceIndex = index;
132
+ event.dataTransfer.effectAllowed = 'move';
133
+ event.dataTransfer.setData('text/plain', String(index));
134
+ },
135
+ onDragOver(index) {
136
+ if (this.dragSourceIndex !== null && index !== this.dragSourceIndex) {
137
+ this.dragOverIndex = index;
138
+ }
139
+ },
140
+ onDragLeave(index) {
141
+ if (this.dragOverIndex === index) {
142
+ this.dragOverIndex = null;
143
+ }
144
+ },
145
+ onDrop(index) {
146
+ if (this.dragSourceIndex === null || this.dragSourceIndex === index) return;
147
+ const moved = this.items.splice(this.dragSourceIndex, 1)[0];
148
+ this.items.splice(index, 0, moved);
149
+ this.dragSourceIndex = null;
150
+ this.dragOverIndex = null;
151
+ this.emitValue();
152
+ },
153
+ onDragEnd() {
154
+ this.dragSourceIndex = null;
155
+ this.dragOverIndex = null;
156
+ },
157
+ toggleCollapseAll() {
158
+ this.allCollapsed = !this.allCollapsed;
159
+ const editors = this.$refs.itemEditors;
160
+ if (!editors) return;
161
+ const list = Array.isArray(editors) ? editors : [editors];
162
+ if (this.allCollapsed) {
163
+ list.forEach(editor => editor?.collapseAll?.());
164
+ } else {
165
+ list.forEach(editor => editor?.expandAll?.());
166
+ }
167
+ },
107
168
  emitValue() {
108
169
  this.$emit('update:modelValue', this.items.map(i => i.value));
109
170
  },
@@ -4,10 +4,10 @@
4
4
  <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
5
5
  <button type="button" :class="toggleClass" @click="toggle">
6
6
  <template v-if="isNull">
7
- <i class="fas fa-plus"></i> Add
7
+ <SfIcon name="plus" /> Add
8
8
  </template>
9
9
  <template v-else>
10
- <i class="fas fa-times"></i> Remove
10
+ <SfIcon name="times" /> Remove
11
11
  </template>
12
12
  </button>
13
13
  </div>
@@ -29,6 +29,7 @@
29
29
 
30
30
  <script>
31
31
  import { getDefaultForSchema } from '../utils';
32
+ import SfIcon from './SfIcon.vue';
32
33
 
33
34
  import SchemaEditor from './SchemaEditor.vue';
34
35
 
@@ -37,6 +38,7 @@ export default {
37
38
  beforeCreate() {
38
39
  if (!this.$options.components) this.$options.components = {};
39
40
  this.$options.components.SchemaEditor = SchemaEditor;
41
+ this.$options.components.SfIcon = SfIcon;
40
42
  },
41
43
  props: {
42
44
  schema: { type: Object, required: true },
@@ -12,9 +12,15 @@
12
12
  />
13
13
  </div>
14
14
  </div>
15
- <fieldset v-else class="sf-object">
16
- <legend class="sf-object-title">{{ title }}</legend>
17
- <div class="sf-object-fields">
15
+ <fieldset v-else class="sf-object" :class="{ 'sf-object-collapsed': collapsed }">
16
+ <legend class="sf-object-title">
17
+ <button type="button" class="sf-collapse-btn" :aria-label="collapsed ? 'Expand' : 'Collapse'" @click="collapsed = !collapsed">
18
+ <SfIcon :name="collapsed ? 'chevron-down' : 'chevron-up'" :size="12" />
19
+ </button>
20
+ <span class="sf-object-title-text">{{ title }}</span>
21
+ <span v-if="collapsed && summary" class="sf-object-summary">{{ summary }}</span>
22
+ </legend>
23
+ <div v-show="!collapsed" class="sf-object-fields">
18
24
  <SchemaEditor
19
25
  v-for="(propSchema, key) in (schema.properties || {})"
20
26
  :key="key"
@@ -30,12 +36,14 @@
30
36
 
31
37
  <script>
32
38
  import SchemaEditor from './SchemaEditor.vue';
39
+ import SfIcon from './SfIcon.vue';
33
40
 
34
41
  export default {
35
42
  name: 'ObjectEditor',
36
43
  beforeCreate() {
37
44
  if (!this.$options.components) this.$options.components = {};
38
45
  this.$options.components.SchemaEditor = SchemaEditor;
46
+ this.$options.components.SfIcon = SfIcon;
39
47
  },
40
48
  props: {
41
49
  schema: { type: Object, required: true },
@@ -44,6 +52,11 @@ export default {
44
52
  form: { type: Object, required: true },
45
53
  },
46
54
  emits: ['update:modelValue'],
55
+ data() {
56
+ return {
57
+ collapsed: false,
58
+ };
59
+ },
47
60
  computed: {
48
61
  isRoot() {
49
62
  return this.path.length === 0;
@@ -51,12 +64,30 @@ export default {
51
64
  title() {
52
65
  return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
53
66
  },
67
+ summary() {
68
+ const val = this.modelValue || {};
69
+ const parts = [];
70
+ for (const key of Object.keys(this.schema.properties || {})) {
71
+ if (parts.length >= 3) break;
72
+ const v = val[key];
73
+ if (v !== null && v !== undefined && v !== '' && typeof v !== 'object') {
74
+ parts.push(String(v));
75
+ }
76
+ }
77
+ return parts.join(' · ');
78
+ },
54
79
  },
55
80
  methods: {
56
81
  humanize(str) {
57
82
  if (!str) return '';
58
83
  return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
59
84
  },
85
+ collapse() {
86
+ this.collapsed = true;
87
+ },
88
+ expand() {
89
+ this.collapsed = false;
90
+ },
60
91
  onChildChange(key, value) {
61
92
  const newVal = { ...(this.modelValue || {}), [key]: value };
62
93
  this.$emit('update:modelValue', newVal);
@@ -8,7 +8,7 @@
8
8
  <span class="sf-relation-tag-text">{{ getDisplayName(item) }}</span>
9
9
  <button v-if="isMultiple || allowClear" type="button" class="sf-relation-tag-remove"
10
10
  @click.stop="removeItem(item)">
11
- <i class="fas fa-times"></i>
11
+ <SfIcon name="times" />
12
12
  </button>
13
13
  </div>
14
14
  </div>
@@ -41,9 +41,11 @@
41
41
 
42
42
  <script>
43
43
  import { debounce } from '../utils';
44
+ import SfIcon from './SfIcon.vue';
44
45
 
45
46
  export default {
46
47
  name: 'RelationEditor',
48
+ components: { SfIcon },
47
49
  props: {
48
50
  schema: { type: Object, required: true },
49
51
  modelValue: { default: null },
@@ -95,8 +97,8 @@ export default {
95
97
  return this.form.getErrorsForPath(this.path);
96
98
  },
97
99
  filteredResults() {
98
- const selectedIds = new Set(this.selected.map(s => `${s.id}-${s.model || ''}`));
99
- return this.searchResults.filter(item => !selectedIds.has(`${item.id}-${item.model || ''}`));
100
+ const selectedIds = new Set(this.selected.map(item => this.itemKey(item)));
101
+ return this.searchResults.filter(item => !selectedIds.has(this.itemKey(item)));
100
102
  },
101
103
  },
102
104
  created() {
@@ -1,5 +1,6 @@
1
1
  <template>
2
2
  <component
3
+ ref="editor"
3
4
  :is="editorComponent"
4
5
  :schema="schema"
5
6
  :model-value="modelValue"
@@ -44,6 +45,18 @@ export default {
44
45
  form: { type: Object, required: true },
45
46
  },
46
47
  emits: ['update:modelValue'],
48
+ methods: {
49
+ collapseAll() {
50
+ const editor = this.$refs.editor;
51
+ if (editor?.collapse) editor.collapse();
52
+ if (editor?.collapseAll) editor.collapseAll();
53
+ },
54
+ expandAll() {
55
+ const editor = this.$refs.editor;
56
+ if (editor?.expand) editor.expand();
57
+ if (editor?.expandAll) editor.expandAll();
58
+ },
59
+ },
47
60
  computed: {
48
61
  editorComponent() {
49
62
  const schema = this.schema;
@@ -0,0 +1,45 @@
1
+ <template>
2
+ <svg class="sf-icon" :width="size" :height="size" viewBox="0 0 24 24" fill="none" stroke="currentColor"
3
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
4
+ <template v-if="name === 'plus'">
5
+ <line x1="12" y1="5" x2="12" y2="19" />
6
+ <line x1="5" y1="12" x2="19" y2="12" />
7
+ </template>
8
+ <template v-else-if="name === 'times'">
9
+ <line x1="6" y1="6" x2="18" y2="18" />
10
+ <line x1="6" y1="18" x2="18" y2="6" />
11
+ </template>
12
+ <template v-else-if="name === 'arrow-up'">
13
+ <line x1="12" y1="19" x2="12" y2="5" />
14
+ <polyline points="5 12 12 5 19 12" />
15
+ </template>
16
+ <template v-else-if="name === 'arrow-down'">
17
+ <line x1="12" y1="5" x2="12" y2="19" />
18
+ <polyline points="19 12 12 19 5 12" />
19
+ </template>
20
+ <template v-else-if="name === 'chevron-down'">
21
+ <polyline points="6 9 12 15 18 9" />
22
+ </template>
23
+ <template v-else-if="name === 'chevron-up'">
24
+ <polyline points="18 15 12 9 6 15" />
25
+ </template>
26
+ <template v-else-if="name === 'grip'">
27
+ <circle cx="9" cy="7" r="1" fill="currentColor" stroke="none" />
28
+ <circle cx="15" cy="7" r="1" fill="currentColor" stroke="none" />
29
+ <circle cx="9" cy="12" r="1" fill="currentColor" stroke="none" />
30
+ <circle cx="15" cy="12" r="1" fill="currentColor" stroke="none" />
31
+ <circle cx="9" cy="17" r="1" fill="currentColor" stroke="none" />
32
+ <circle cx="15" cy="17" r="1" fill="currentColor" stroke="none" />
33
+ </template>
34
+ </svg>
35
+ </template>
36
+
37
+ <script>
38
+ export default {
39
+ name: 'SfIcon',
40
+ props: {
41
+ name: { type: String, required: true },
42
+ size: { type: [Number, String], default: 14 },
43
+ },
44
+ };
45
+ </script>
@@ -21,10 +21,17 @@
21
21
  padding: 12px;
22
22
  margin-bottom: 12px;
23
23
  background: var(--body-bg, #fff);
24
+
25
+ &.sf-object-collapsed {
26
+ padding: 0;
27
+ }
24
28
  }
25
29
  }
26
30
 
27
31
  .sf-object-title {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 6px;
28
35
  font-weight: 600;
29
36
  font-size: 0.8125rem;
30
37
  color: var(--body-fg, #333);
@@ -32,6 +39,40 @@
32
39
  padding: 0 4px;
33
40
  }
34
41
 
42
+ .sf-object-title-text {
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ .sf-object-summary {
47
+ font-weight: 400;
48
+ font-size: 0.75rem;
49
+ color: var(--body-quiet-color, #888);
50
+ white-space: nowrap;
51
+ overflow: hidden;
52
+ text-overflow: ellipsis;
53
+ max-width: 240px;
54
+ }
55
+
56
+ .sf-collapse-btn {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ justify-content: center;
60
+ width: 18px;
61
+ height: 18px;
62
+ padding: 0;
63
+ border: none;
64
+ background: transparent;
65
+ cursor: pointer;
66
+ color: var(--body-quiet-color, #888);
67
+ border-radius: 3px;
68
+ flex-shrink: 0;
69
+
70
+ &:hover {
71
+ background: var(--darkened-bg, #f0f0f0);
72
+ color: var(--body-fg, #333);
73
+ }
74
+ }
75
+
35
76
  .sf-object-fields {
36
77
  display: flex;
37
78
  flex-direction: column;
@@ -53,6 +94,19 @@
53
94
  }
54
95
  }
55
96
 
97
+ .sf-array-collapse-toggle {
98
+ margin-left: auto;
99
+ display: inline-flex;
100
+ align-items: center;
101
+ cursor: pointer;
102
+ color: var(--body-quiet-color, #aaa);
103
+ line-height: 0;
104
+
105
+ &:hover {
106
+ color: var(--body-fg, #333);
107
+ }
108
+ }
109
+
56
110
  .sf-array-count {
57
111
  display: inline-flex;
58
112
  align-items: center;
@@ -80,6 +134,21 @@
80
134
  overflow: hidden;
81
135
  }
82
136
 
137
+ .sf-array-item {
138
+ &[draggable="true"] {
139
+ cursor: grab;
140
+ }
141
+
142
+ &.sf-dragging {
143
+ opacity: 0.4;
144
+ }
145
+
146
+ &.sf-drag-over {
147
+ outline: 2px solid var(--primary-color, #3b82f6);
148
+ outline-offset: -2px;
149
+ }
150
+ }
151
+
83
152
  .sf-array-item-header {
84
153
  display: flex;
85
154
  align-items: center;
@@ -89,6 +158,29 @@
89
158
  border-bottom: 1px solid var(--border-color, #ccc);
90
159
  }
91
160
 
161
+ .sf-array-item-left {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 6px;
165
+ }
166
+
167
+ .sf-drag-handle {
168
+ display: inline-flex;
169
+ align-items: center;
170
+ color: var(--body-quiet-color, #aaa);
171
+ cursor: grab;
172
+ padding: 0 2px;
173
+ line-height: 0;
174
+
175
+ &:active {
176
+ cursor: grabbing;
177
+ }
178
+
179
+ &:hover {
180
+ color: var(--body-fg, #555);
181
+ }
182
+ }
183
+
92
184
  .sf-array-item-index {
93
185
  font-size: 0.6875rem;
94
186
  font-weight: 600;
@@ -46,8 +46,13 @@
46
46
  }
47
47
 
48
48
  .sf-select {
49
- appearance: auto;
49
+ appearance: none;
50
50
  cursor: pointer;
51
+ padding-right: 28px;
52
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='12' height='12'%3E%3Cpath fill='%23888' d='M12 15L5 8h14z'/%3E%3C/svg%3E");
53
+ background-repeat: no-repeat;
54
+ background-position: right 8px center;
55
+ background-size: 12px 12px;
51
56
  }
52
57
 
53
58
  .sf-checkbox {
@@ -1,3 +1,3 @@
1
- @import './components/form.scss';
2
- @import './components/editors.scss';
3
- @import './components/relation.scss';
1
+ @use './components/form';
2
+ @use './components/editors';
3
+ @use './components/relation';