@structured-field/widget-editor 1.0.1 → 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.1",
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",
@@ -62,6 +66,11 @@
62
66
  "branches": [
63
67
  "master"
64
68
  ],
69
+ "verifyConditions": [
70
+ "@semantic-release/changelog",
71
+ "@semantic-release/git",
72
+ "@semantic-release/github"
73
+ ],
65
74
  "plugins": [
66
75
  "@semantic-release/commit-analyzer",
67
76
  "@semantic-release/release-notes-generator",
@@ -24,6 +24,7 @@ export default {
24
24
  errors: { type: Object, default: () => ({}) },
25
25
  },
26
26
  emits: ['change'],
27
+ expose: ['getValue'],
27
28
  data() {
28
29
  const parsedSchema = typeof this.schema === 'string' ? JSON.parse(this.schema) : this.schema;
29
30
  const defs = parsedSchema.$defs || parsedSchema.definitions || {};
@@ -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)]"
@@ -41,14 +59,20 @@
41
59
  </template>
42
60
 
43
61
  <script>
44
- import { defineAsyncComponent } from 'vue';
45
62
  import { getDefaultForSchema } from '../utils';
63
+ import SfIcon from './SfIcon.vue';
46
64
 
47
65
  let keyCounter = 0;
48
66
 
67
+ import SchemaEditor from './SchemaEditor.vue';
68
+
49
69
  export default {
50
70
  name: 'ArrayEditor',
51
- components: { SchemaEditor: defineAsyncComponent(() => import('./SchemaEditor.vue')) },
71
+ beforeCreate() {
72
+ if (!this.$options.components) this.$options.components = {};
73
+ this.$options.components.SchemaEditor = SchemaEditor;
74
+ this.$options.components.SfIcon = SfIcon;
75
+ },
52
76
  props: {
53
77
  schema: { type: Object, required: true },
54
78
  modelValue: { default: () => [] },
@@ -60,6 +84,9 @@ export default {
60
84
  const arr = Array.isArray(this.modelValue) ? this.modelValue : [];
61
85
  return {
62
86
  items: arr.map(v => ({ _key: keyCounter++, value: v })),
87
+ dragSourceIndex: null,
88
+ dragOverIndex: null,
89
+ allCollapsed: false,
63
90
  };
64
91
  },
65
92
  computed: {
@@ -100,6 +127,44 @@ export default {
100
127
  this.items[index].value = value;
101
128
  this.emitValue();
102
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
+ },
103
168
  emitValue() {
104
169
  this.$emit('update:modelValue', this.items.map(i => i.value));
105
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>
@@ -28,12 +28,18 @@
28
28
  </template>
29
29
 
30
30
  <script>
31
- import { defineAsyncComponent } from 'vue';
32
31
  import { getDefaultForSchema } from '../utils';
32
+ import SfIcon from './SfIcon.vue';
33
+
34
+ import SchemaEditor from './SchemaEditor.vue';
33
35
 
34
36
  export default {
35
37
  name: 'NullableEditor',
36
- components: { SchemaEditor: defineAsyncComponent(() => import('./SchemaEditor.vue')) },
38
+ beforeCreate() {
39
+ if (!this.$options.components) this.$options.components = {};
40
+ this.$options.components.SchemaEditor = SchemaEditor;
41
+ this.$options.components.SfIcon = SfIcon;
42
+ },
37
43
  props: {
38
44
  schema: { type: Object, required: true },
39
45
  modelValue: { default: null },
@@ -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"
@@ -29,11 +35,16 @@
29
35
  </template>
30
36
 
31
37
  <script>
32
- import { defineAsyncComponent } from 'vue';
38
+ import SchemaEditor from './SchemaEditor.vue';
39
+ import SfIcon from './SfIcon.vue';
33
40
 
34
41
  export default {
35
42
  name: 'ObjectEditor',
36
- components: { SchemaEditor: defineAsyncComponent(() => import('./SchemaEditor.vue')) },
43
+ beforeCreate() {
44
+ if (!this.$options.components) this.$options.components = {};
45
+ this.$options.components.SchemaEditor = SchemaEditor;
46
+ this.$options.components.SfIcon = SfIcon;
47
+ },
37
48
  props: {
38
49
  schema: { type: Object, required: true },
39
50
  modelValue: { default: () => ({}) },
@@ -41,6 +52,11 @@ export default {
41
52
  form: { type: Object, required: true },
42
53
  },
43
54
  emits: ['update:modelValue'],
55
+ data() {
56
+ return {
57
+ collapsed: false,
58
+ };
59
+ },
44
60
  computed: {
45
61
  isRoot() {
46
62
  return this.path.length === 0;
@@ -48,12 +64,30 @@ export default {
48
64
  title() {
49
65
  return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
50
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
+ },
51
79
  },
52
80
  methods: {
53
81
  humanize(str) {
54
82
  if (!str) return '';
55
83
  return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
56
84
  },
85
+ collapse() {
86
+ this.collapsed = true;
87
+ },
88
+ expand() {
89
+ this.collapsed = false;
90
+ },
57
91
  onChildChange(key, value) {
58
92
  const newVal = { ...(this.modelValue || {}), [key]: value };
59
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>
@@ -20,12 +20,16 @@
20
20
  </template>
21
21
 
22
22
  <script>
23
- import { defineAsyncComponent } from 'vue';
24
23
  import { getDefaultForSchema } from '../utils';
25
24
 
25
+ import SchemaEditor from './SchemaEditor.vue';
26
+
26
27
  export default {
27
28
  name: 'UnionEditor',
28
- components: { SchemaEditor: defineAsyncComponent(() => import('./SchemaEditor.vue')) },
29
+ beforeCreate() {
30
+ if (!this.$options.components) this.$options.components = {};
31
+ this.$options.components.SchemaEditor = SchemaEditor;
32
+ },
29
33
  props: {
30
34
  schema: { type: Object, required: true },
31
35
  modelValue: { default: null },
package/src/index.js CHANGED
@@ -13,140 +13,13 @@ export { default as NullableEditor } from './editors/NullableEditor.vue';
13
13
  export { default as UnionEditor } from './editors/UnionEditor.vue';
14
14
  export { default as RelationEditor } from './editors/RelationEditor.vue';
15
15
 
16
- import { createApp, h, ref, reactive } from 'vue';
16
+ import { defineCustomElement } from 'vue';
17
17
  import SchemaFormComponent from './SchemaForm.vue';
18
18
 
19
- export class SchemaFormElement extends HTMLElement {
20
- constructor() {
21
- super();
22
- this._formRef = ref(null);
23
- this._props = reactive({
24
- schema: {},
25
- initialData: undefined,
26
- errors: {},
27
- });
28
- this._app = null;
29
- this._mountPoint = null;
30
- }
31
-
32
- connectedCallback() {
33
- this._mountPoint = document.createElement('div');
34
- this.appendChild(this._mountPoint);
35
-
36
- // Read from attributes if properties haven't been set programmatically
37
- if (!this._propsSet) {
38
- const schemaAttr = this.getAttribute('schema');
39
- const dataAttr = this.getAttribute('initial-data');
40
- if (schemaAttr) this._props.schema = JSON.parse(schemaAttr);
41
- if (dataAttr) this._props.initialData = JSON.parse(dataAttr);
42
- }
43
-
44
- const formRef = this._formRef;
45
- const props = this._props;
46
-
47
- this._app = createApp({
48
- render: () => h(SchemaFormComponent, {
49
- ref: formRef,
50
- schema: props.schema,
51
- initialData: props.initialData,
52
- errors: props.errors,
53
- onChange: (val) => {
54
- this.dispatchEvent(new CustomEvent('change', { detail: val, bubbles: true }));
55
- },
56
- }),
57
- });
58
-
59
- this._app.mount(this._mountPoint);
60
- }
61
-
62
- disconnectedCallback() {
63
- if (this._app) {
64
- this._app.unmount();
65
- this._app = null;
66
- }
67
- this._mountPoint = null;
68
- }
69
-
70
- // --- Property API ---
71
-
72
- get schema() {
73
- return this._props.schema;
74
- }
75
-
76
- set schema(val) {
77
- this._propsSet = true;
78
- this._props.schema = typeof val === 'string' ? JSON.parse(val) : val;
79
- this._rerender();
80
- }
81
-
82
- get initialData() {
83
- return this._props.initialData;
84
- }
85
-
86
- set initialData(val) {
87
- this._propsSet = true;
88
- this._props.initialData = typeof val === 'string' ? JSON.parse(val) : val;
89
- this._rerender();
90
- }
91
-
92
- get errors() {
93
- return this._props.errors;
94
- }
95
-
96
- set errors(val) {
97
- this._propsSet = true;
98
- this._props.errors = typeof val === 'string' ? JSON.parse(val) : (val || {});
99
- this._rerender();
100
- }
101
-
102
- // --- Attribute reflection ---
103
-
104
- static get observedAttributes() {
105
- return ['schema', 'initial-data', 'errors'];
106
- }
107
-
108
- attributeChangedCallback(name, oldVal, newVal) {
109
- if (oldVal === newVal || this._propsSet) return;
110
- if (name === 'schema') {
111
- this._props.schema = newVal ? JSON.parse(newVal) : {};
112
- } else if (name === 'initial-data') {
113
- this._props.initialData = newVal ? JSON.parse(newVal) : undefined;
114
- } else if (name === 'errors') {
115
- this._props.errors = newVal ? JSON.parse(newVal) : {};
116
- }
117
- this._rerender();
118
- }
119
-
120
- // --- Public methods ---
121
-
122
- getValue() {
123
- return this._formRef.value?.getValue?.() ?? null;
124
- }
125
-
126
- // --- Internal ---
127
-
128
- _rerender() {
129
- if (!this._app) return;
130
- this._app.unmount();
131
- this._mountPoint.innerHTML = '';
132
- const formRef = this._formRef;
133
- const props = this._props;
134
-
135
- this._app = createApp({
136
- render: () => h(SchemaFormComponent, {
137
- ref: formRef,
138
- schema: props.schema,
139
- initialData: props.initialData,
140
- errors: props.errors,
141
- onChange: (val) => {
142
- this.dispatchEvent(new CustomEvent('change', { detail: val, bubbles: true }));
143
- },
144
- }),
145
- });
146
-
147
- this._app.mount(this._mountPoint);
148
- }
149
- }
19
+ export const SchemaFormElement = defineCustomElement({
20
+ ...SchemaFormComponent,
21
+ shadowRoot: false,
22
+ });
150
23
 
151
24
  export function registerCustomElement(tagName = 'schema-form') {
152
25
  if (!customElements.get(tagName)) {
@@ -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;