@structured-field/widget-editor 1.1.1 → 1.2.1
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/README.md +284 -0
- package/dist/structured-widget-editor.css +1 -1
- package/dist/structured-widget-editor.esm.js +618 -60
- package/dist/structured-widget-editor.esm.js.map +1 -1
- package/dist/structured-widget-editor.iife.js +5 -6
- package/dist/structured-widget-editor.js +4 -5
- package/dist/structured-widget-editor.js.map +1 -1
- package/package.json +1 -1
- package/src/BaseEditorElement.js +108 -0
- package/src/SchemaForm.vue +32 -0
- package/src/conditionals.js +248 -0
- package/src/editors/ObjectEditor.vue +24 -4
- package/src/editors/RelationEditor.vue +5 -0
- package/src/editors/SchemaEditor.vue +25 -0
- package/src/editors/WebComponentWrapper.vue +55 -0
- package/src/index.js +3 -0
- package/src/scss/components/editors.scss +3 -1
package/README.md
CHANGED
|
@@ -197,6 +197,290 @@ Query parameters sent: `_q` (search term), `page` (pagination).
|
|
|
197
197
|
| `oneOf + discriminator` | UnionEditor | Type selector |
|
|
198
198
|
| `relation` | RelationEditor | Autocomplete with search |
|
|
199
199
|
|
|
200
|
+
## Custom Editors
|
|
201
|
+
|
|
202
|
+
You can override the editor used for any field by passing a `customEditors` array to `SchemaForm`. Each entry defines a `match` condition and the `component` to render when that condition is true. Overrides are evaluated **before** the built-in resolution logic, in order — the first match wins.
|
|
203
|
+
|
|
204
|
+
### Vue component
|
|
205
|
+
|
|
206
|
+
```vue
|
|
207
|
+
<template>
|
|
208
|
+
<SchemaForm :schema="schema" :custom-editors="customEditors" />
|
|
209
|
+
</template>
|
|
210
|
+
|
|
211
|
+
<script setup>
|
|
212
|
+
import { SchemaForm } from '@structured-field/widget-editor';
|
|
213
|
+
import MyDatePicker from './MyDatePicker.vue';
|
|
214
|
+
import MyColorPicker from './MyColorPicker.vue';
|
|
215
|
+
|
|
216
|
+
const customEditors = [
|
|
217
|
+
// Match by schema format
|
|
218
|
+
{ match: (schema) => schema.format === 'date', component: MyDatePicker },
|
|
219
|
+
// Match by field name (last segment of the path)
|
|
220
|
+
{ match: (schema, path) => path.at(-1) === 'color', component: MyColorPicker },
|
|
221
|
+
// Match by a custom schema property
|
|
222
|
+
{ match: (schema) => schema['x-widget'] === 'rich-text', component: MyRichText },
|
|
223
|
+
];
|
|
224
|
+
</script>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Web Component (custom element)
|
|
228
|
+
|
|
229
|
+
```js
|
|
230
|
+
const el = document.querySelector('schema-form');
|
|
231
|
+
|
|
232
|
+
el.customEditors = [
|
|
233
|
+
// Vue component — pass the component object
|
|
234
|
+
{ match: (schema) => schema.format === 'date', component: MyDatePicker },
|
|
235
|
+
// Web Component — pass the tag name as a string
|
|
236
|
+
{ match: (schema, path) => path.at(-1) === 'color', component: 'my-color-picker' },
|
|
237
|
+
];
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
When `component` is a **string containing a hyphen**, it is treated as a web component tag name. The editor wrapper will:
|
|
241
|
+
|
|
242
|
+
- Set `schema`, `modelValue`, `path`, and `form` as **JS properties** on the element (not HTML attributes)
|
|
243
|
+
- Listen for `change` or `update:model-value` `CustomEvent`s to receive the new value
|
|
244
|
+
|
|
245
|
+
### Custom editor API
|
|
246
|
+
|
|
247
|
+
#### Vue component
|
|
248
|
+
|
|
249
|
+
A custom editor Vue component must accept the following props and emit `update:modelValue` to write values back. **Never mutate `modelValue` directly.**
|
|
250
|
+
|
|
251
|
+
| Prop | Type | Description |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `schema` | `Object` | The resolved JSON Schema for this field |
|
|
254
|
+
| `modelValue` | `any` | Current field value — read-only |
|
|
255
|
+
| `path` | `string[]` | Path segments from the root (`['address', 'city']`) |
|
|
256
|
+
| `form` | `Object` | Form API: `getSchemaAtPath(path)`, `getErrorsForPath(path)`, `resolveSchema(schema)` |
|
|
257
|
+
|
|
258
|
+
| Emit | Payload | Description |
|
|
259
|
+
|---|---|---|
|
|
260
|
+
| `update:modelValue` | new value | The only way to write a value back to the form |
|
|
261
|
+
|
|
262
|
+
#### Web Component
|
|
263
|
+
|
|
264
|
+
A custom editor web component receives the same data as **JS properties** and dispatches a `change` `CustomEvent` with the new value as `detail`:
|
|
265
|
+
|
|
266
|
+
| Property | Type | Description |
|
|
267
|
+
|---|---|---|
|
|
268
|
+
| `schema` | `Object` | The resolved JSON Schema for this field |
|
|
269
|
+
| `modelValue` | `any` | Current field value — read-only |
|
|
270
|
+
| `path` | `string[]` | Path segments from the root |
|
|
271
|
+
| `form` | `Object` | Form API: `getSchemaAtPath(path)`, `getErrorsForPath(path)`, `resolveSchema(schema)` |
|
|
272
|
+
|
|
273
|
+
| Event | Detail | Description |
|
|
274
|
+
|---|---|---|
|
|
275
|
+
| `change` | new value | Dispatched to write a value back to the form |
|
|
276
|
+
|
|
277
|
+
### Starter templates
|
|
278
|
+
|
|
279
|
+
#### Vue component
|
|
280
|
+
|
|
281
|
+
Copy and adapt this component as a starting point:
|
|
282
|
+
|
|
283
|
+
```vue
|
|
284
|
+
<template>
|
|
285
|
+
<div class="sf-field" :class="{ errors: fieldErrors.length }">
|
|
286
|
+
<label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
|
|
287
|
+
|
|
288
|
+
<!-- Replace this with your custom input -->
|
|
289
|
+
<input
|
|
290
|
+
type="text"
|
|
291
|
+
class="sf-input"
|
|
292
|
+
:value="modelValue"
|
|
293
|
+
@input="$emit('update:modelValue', $event.target.value)"
|
|
294
|
+
/>
|
|
295
|
+
|
|
296
|
+
<ul v-if="fieldErrors.length" class="errorlist">
|
|
297
|
+
<li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
|
|
298
|
+
</ul>
|
|
299
|
+
</div>
|
|
300
|
+
</template>
|
|
301
|
+
|
|
302
|
+
<script>
|
|
303
|
+
export default {
|
|
304
|
+
name: 'MyCustomEditor',
|
|
305
|
+
props: {
|
|
306
|
+
schema: { type: Object, required: true },
|
|
307
|
+
modelValue: { default: undefined },
|
|
308
|
+
path: { type: Array, default: () => [] },
|
|
309
|
+
form: { type: Object, default: null },
|
|
310
|
+
},
|
|
311
|
+
emits: ['update:modelValue'],
|
|
312
|
+
computed: {
|
|
313
|
+
title() {
|
|
314
|
+
return this.schema.title || this.humanize(this.path.at(-1)) || '';
|
|
315
|
+
},
|
|
316
|
+
isRequired() {
|
|
317
|
+
if (this.path.length < 2 || !this.form) return false;
|
|
318
|
+
const parentSchema = this.form.getSchemaAtPath(this.path.slice(0, -1));
|
|
319
|
+
return Array.isArray(parentSchema?.required) && parentSchema.required.includes(this.path.at(-1));
|
|
320
|
+
},
|
|
321
|
+
fieldErrors() {
|
|
322
|
+
return this.form?.getErrorsForPath?.(this.path) ?? [];
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
methods: {
|
|
326
|
+
humanize(str) {
|
|
327
|
+
if (!str) return '';
|
|
328
|
+
return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
</script>
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
#### Web Component (using `BaseEditorElement`)
|
|
336
|
+
|
|
337
|
+
The `BaseEditorElement` base class handles the property contract for you. Override `render()` to build the initial DOM and `update()` to react to property changes. Use `this.emitChange(value)` to send values back and `this.getErrors()` to read validation errors.
|
|
338
|
+
|
|
339
|
+
**ESM:**
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
import { BaseEditorElement } from '@structured-field/widget-editor';
|
|
343
|
+
|
|
344
|
+
class MyColorPicker extends BaseEditorElement {
|
|
345
|
+
render() {
|
|
346
|
+
const wrapper = document.createElement('div');
|
|
347
|
+
wrapper.className = 'sf-field';
|
|
348
|
+
|
|
349
|
+
const label = document.createElement('label');
|
|
350
|
+
label.className = 'sf-label';
|
|
351
|
+
label.textContent = this.schema?.title || 'Color';
|
|
352
|
+
this._label = label;
|
|
353
|
+
|
|
354
|
+
const input = document.createElement('input');
|
|
355
|
+
input.type = 'color';
|
|
356
|
+
input.className = 'sf-input';
|
|
357
|
+
input.value = this.modelValue || '#000000';
|
|
358
|
+
input.addEventListener('input', () => this.emitChange(input.value));
|
|
359
|
+
this._input = input;
|
|
360
|
+
|
|
361
|
+
wrapper.appendChild(label);
|
|
362
|
+
wrapper.appendChild(input);
|
|
363
|
+
this.appendChild(wrapper);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
update() {
|
|
367
|
+
if (this._label) this._label.textContent = this.schema?.title || 'Color';
|
|
368
|
+
if (this._input) this._input.value = this.modelValue || '#000000';
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
customElements.define('my-color-picker', MyColorPicker);
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**IIFE:**
|
|
376
|
+
|
|
377
|
+
```html
|
|
378
|
+
<script src="https://bnznamco.github.io/structured-widget-editor/latest/structured-widget-editor.iife.js"></script>
|
|
379
|
+
<script>
|
|
380
|
+
var BaseEditorElement = StructuredWidgetEditor.BaseEditorElement;
|
|
381
|
+
|
|
382
|
+
class MyColorPicker extends BaseEditorElement {
|
|
383
|
+
render() {
|
|
384
|
+
var input = document.createElement('input');
|
|
385
|
+
input.type = 'color';
|
|
386
|
+
input.value = this.modelValue || '#000000';
|
|
387
|
+
input.addEventListener('input', () => this.emitChange(input.value));
|
|
388
|
+
this._input = input;
|
|
389
|
+
this.appendChild(input);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
update() {
|
|
393
|
+
if (this._input) this._input.value = this.modelValue || '#000000';
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
customElements.define('my-color-picker', MyColorPicker);
|
|
398
|
+
</script>
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
#### `BaseEditorElement` API
|
|
402
|
+
|
|
403
|
+
| Property / Method | Description |
|
|
404
|
+
|---|---|
|
|
405
|
+
| `this.schema` | The resolved JSON Schema for this field |
|
|
406
|
+
| `this.modelValue` | Current field value (read-only) |
|
|
407
|
+
| `this.path` | Path segments from the root |
|
|
408
|
+
| `this.form` | Form API object |
|
|
409
|
+
| `this.emitChange(value)` | Dispatch the new value back to the form |
|
|
410
|
+
| `this.getErrors()` | Returns `string[]` of validation errors for this field |
|
|
411
|
+
| `render()` | **Override.** Called once when connected — build the DOM here |
|
|
412
|
+
| `update()` | **Override.** Called on every property change after `render()` |
|
|
413
|
+
|
|
414
|
+
## Conditional fields
|
|
415
|
+
|
|
416
|
+
The form renderer evaluates standard JSON Schema conditional keywords on every change and updates the visible/required fields accordingly. No custom keywords — anything you put in the schema is also enforced server-side by Pydantic v2.
|
|
417
|
+
|
|
418
|
+
Supported keywords on object schemas:
|
|
419
|
+
|
|
420
|
+
| Keyword | Use case |
|
|
421
|
+
|---|---|
|
|
422
|
+
| `if` / `then` / `else` | "If `status == 'archived'`, require `archive_reason`" |
|
|
423
|
+
| `allOf: [{ if, then }, ...]` | Multiple independent rules on the same object |
|
|
424
|
+
| `dependentSchemas` | "If field `publisher` is present, also show `edition`" |
|
|
425
|
+
| `dependentRequired` | Lighter version: only toggles `required` |
|
|
426
|
+
|
|
427
|
+
Example schema fragment:
|
|
428
|
+
|
|
429
|
+
```json
|
|
430
|
+
{
|
|
431
|
+
"type": "object",
|
|
432
|
+
"properties": {
|
|
433
|
+
"status": { "enum": ["draft", "archived"] }
|
|
434
|
+
},
|
|
435
|
+
"allOf": [
|
|
436
|
+
{
|
|
437
|
+
"if": { "properties": { "status": { "const": "archived" } }, "required": ["status"] },
|
|
438
|
+
"then": {
|
|
439
|
+
"properties": { "archive_reason": { "type": "string" } },
|
|
440
|
+
"required": ["archive_reason"]
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
],
|
|
444
|
+
"dependentSchemas": {
|
|
445
|
+
"publisher": { "properties": { "edition": { "type": "string" } } }
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Fields that disappear when a rule stops matching are pruned from the form value, so the emitted JSON stays clean.
|
|
451
|
+
|
|
452
|
+
### Declaring conditionals on a Pydantic model
|
|
453
|
+
|
|
454
|
+
`django-structured-field` ships matching helpers in `structured.pydantic.conditionals` that compile down to the same standard keywords:
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
from typing import Literal, Optional
|
|
458
|
+
from pydantic import ConfigDict
|
|
459
|
+
from structured.pydantic.models import BaseModel
|
|
460
|
+
from structured.pydantic.conditionals import (
|
|
461
|
+
When, conditional_schema, dependent_schemas,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
class Book(BaseModel):
|
|
465
|
+
status: Literal["draft", "review", "published", "archived"] = "draft"
|
|
466
|
+
archive_reason: Optional[str] = None
|
|
467
|
+
published_at: Optional[str] = None
|
|
468
|
+
publisher: Optional[str] = None
|
|
469
|
+
edition: Optional[str] = None
|
|
470
|
+
|
|
471
|
+
model_config = ConfigDict(json_schema_extra=conditional_schema(
|
|
472
|
+
When("status", equals="archived",
|
|
473
|
+
then={"required": ["archive_reason"]}),
|
|
474
|
+
When("status", equals="published",
|
|
475
|
+
then={"required": ["published_at"]}),
|
|
476
|
+
dependent_schemas(publisher={
|
|
477
|
+
"properties": {"edition": {"type": "string"}}
|
|
478
|
+
}),
|
|
479
|
+
))
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
`When(field, equals=..., in_=..., not_equals=..., then=..., else_=...)` builds a single `if/then/else` clause; `conditional_schema(...)` groups them into `allOf` and merges any `dependent_schemas` / `dependent_required` fragments.
|
|
483
|
+
|
|
200
484
|
## Theming
|
|
201
485
|
|
|
202
486
|
All styles use CSS custom properties with sensible defaults. Override them to match your design system:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.structured-field-editor{width:100%;border-radius:4px;font-size:.8125rem}.structured-field-editor *,.structured-field-editor *::before,.structured-field-editor *::after{box-sizing:border-box}.structured-field-editor .sf-label{display:block;font-weight:600;margin-bottom:4px;text-transform:capitalize;color:var(--body-quiet-color, #666);font-size:.8125rem}.structured-field-editor .sf-label.required::after{content:" *";color:var(--error-fg, #ba2121)}.structured-field-editor .sf-input{display:block;width:100%;padding:6px 8px;border:1px solid var(--border-color, #ccc);border-radius:4px;background:var(--body-bg, #fff);color:var(--body-fg, #333);font-size:.8125rem;font-family:inherit;transition:border-color .15s}.structured-field-editor .sf-input:focus{outline:none;border-color:var(--primary, #79aec8);box-shadow:0 0 0 2px rgba(121,174,200,.2)}.structured-field-editor .sf-textarea{resize:vertical;min-height:60px}.structured-field-editor .sf-select{appearance:none;cursor:pointer;padding-right:28px;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");background-repeat:no-repeat;background-position:right 8px center;background-size:12px 12px}.structured-field-editor .sf-checkbox{margin-right:6px;accent-color:var(--primary, #79aec8)}.structured-field-editor .sf-checkbox-label{display:flex;align-items:center;font-weight:normal;cursor:pointer;padding:6px 0}.structured-field-editor .sf-btn{display:inline-flex;align-items:center;gap:4px;padding:4px 12px;background:var(--object-tools-bg, #888);color:var(--object-tools-fg, #fff);font-weight:400;font-size:.6875rem;text-transform:uppercase;letter-spacing:.5px;border-radius:15px;border:none;cursor:pointer;transition:background-color .15s}.structured-field-editor .sf-btn:hover{background-color:var(--object-tools-hover-bg, #666)}.structured-field-editor .sf-btn i{font-size:.6rem}.structured-field-editor .sf-btn-sm{padding:2px 8px;font-size:.625rem}.structured-field-editor .sf-btn-add{background:var(--object-tools-bg, #888);color:var(--object-tools-fg, #fff)}.structured-field-editor .sf-btn-danger{background:var(--delete-button-bg, #ba2121);color:var(--delete-button-fg, #fff)}.structured-field-editor .sf-btn-danger:hover{background:#a41515}.structured-field-editor .sf-error{color:var(--error-fg, #ba2121);font-size:.75rem;margin-top:2px}.structured-field-editor .errors .sf-input{border-color:var(--error-fg, #ba2121)}.structured-field-editor .errorlist{margin:4px 0 0 0;padding:0;list-style:none;color:var(--error-fg, #ba2121);font-size:.75rem}.structured-field-editor .errorlist li{padding:2px 0}.form-row{overflow:visible !important}.structured-field-editor .sf-field{margin-bottom:12px}.structured-field-editor .sf-field-boolean{margin-bottom:8px}.structured-field-editor .sf-object:not(.sf-object-root){border:1px solid var(--border-color, #ccc);border-radius:4px;padding:12px;margin-bottom:12px;background:var(--body-bg, #fff)}.structured-field-editor .sf-object:not(.sf-object-root).sf-object-collapsed{padding:0}.structured-field-editor .sf-object-title{display:flex;align-items:center;gap:6px;font-weight:600;font-size:.8125rem;color:var(--body-fg, #333);text-transform:capitalize;padding:0 4px}.structured-field-editor .sf-object-title-text{flex-shrink:0}.structured-field-editor .sf-object-summary{font-weight:400;font-size:.75rem;color:var(--body-quiet-color, #888);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px}.structured-field-editor .sf-collapse-btn{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:none;background:rgba(0,0,0,0);cursor:pointer;color:var(--body-quiet-color, #888);border-radius:3px;flex-shrink:0}.structured-field-editor .sf-collapse-btn:hover{background:var(--darkened-bg, #f0f0f0);color:var(--body-fg, #333)}.structured-field-editor .sf-object-fields{display:flex;flex-direction:column}.structured-field-editor .sf-array{margin-bottom:12px}.structured-field-editor .sf-array-header{display:flex;align-items:center;gap:8px;margin-bottom:8px}.structured-field-editor .sf-array-header .sf-label{margin-bottom:0}.structured-field-editor .sf-array-collapse-toggle{margin-left:auto;display:inline-flex;align-items:center;cursor:pointer;color:var(--body-quiet-color, #aaa);line-height:0}.structured-field-editor .sf-array-collapse-toggle:hover{color:var(--body-fg, #333)}.structured-field-editor .sf-array-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 6px;background:var(--darkened-bg, #f0f0f0);border-radius:10px;font-size:.6875rem;font-weight:600;color:var(--body-quiet-color, #666)}.structured-field-editor .sf-array-items{display:flex;flex-direction:column;gap:8px}.structured-field-editor .sf-array-item{border:1px solid var(--border-color, #ccc);border-radius:4px;background:var(--darkened-bg, #f8f8f8);overflow:
|
|
1
|
+
.structured-field-editor{width:100%;border-radius:4px;font-size:.8125rem}.structured-field-editor *,.structured-field-editor *::before,.structured-field-editor *::after{box-sizing:border-box}.structured-field-editor .sf-label{display:block;font-weight:600;margin-bottom:4px;text-transform:capitalize;color:var(--body-quiet-color, #666);font-size:.8125rem}.structured-field-editor .sf-label.required::after{content:" *";color:var(--error-fg, #ba2121)}.structured-field-editor .sf-input{display:block;width:100%;padding:6px 8px;border:1px solid var(--border-color, #ccc);border-radius:4px;background:var(--body-bg, #fff);color:var(--body-fg, #333);font-size:.8125rem;font-family:inherit;transition:border-color .15s}.structured-field-editor .sf-input:focus{outline:none;border-color:var(--primary, #79aec8);box-shadow:0 0 0 2px rgba(121,174,200,.2)}.structured-field-editor .sf-textarea{resize:vertical;min-height:60px}.structured-field-editor .sf-select{appearance:none;cursor:pointer;padding-right:28px;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");background-repeat:no-repeat;background-position:right 8px center;background-size:12px 12px}.structured-field-editor .sf-checkbox{margin-right:6px;accent-color:var(--primary, #79aec8)}.structured-field-editor .sf-checkbox-label{display:flex;align-items:center;font-weight:normal;cursor:pointer;padding:6px 0}.structured-field-editor .sf-btn{display:inline-flex;align-items:center;gap:4px;padding:4px 12px;background:var(--object-tools-bg, #888);color:var(--object-tools-fg, #fff);font-weight:400;font-size:.6875rem;text-transform:uppercase;letter-spacing:.5px;border-radius:15px;border:none;cursor:pointer;transition:background-color .15s}.structured-field-editor .sf-btn:hover{background-color:var(--object-tools-hover-bg, #666)}.structured-field-editor .sf-btn i{font-size:.6rem}.structured-field-editor .sf-btn-sm{padding:2px 8px;font-size:.625rem}.structured-field-editor .sf-btn-add{background:var(--object-tools-bg, #888);color:var(--object-tools-fg, #fff)}.structured-field-editor .sf-btn-danger{background:var(--delete-button-bg, #ba2121);color:var(--delete-button-fg, #fff)}.structured-field-editor .sf-btn-danger:hover{background:#a41515}.structured-field-editor .sf-error{color:var(--error-fg, #ba2121);font-size:.75rem;margin-top:2px}.structured-field-editor .errors .sf-input{border-color:var(--error-fg, #ba2121)}.structured-field-editor .errorlist{margin:4px 0 0 0;padding:0;list-style:none;color:var(--error-fg, #ba2121);font-size:.75rem}.structured-field-editor .errorlist li{padding:2px 0}.form-row{overflow:visible !important}.structured-field-editor .sf-field{margin-bottom:12px}.structured-field-editor .sf-field-boolean{margin-bottom:8px}.structured-field-editor .sf-object:not(.sf-object-root){border:1px solid var(--border-color, #ccc);border-radius:4px;padding:12px;margin-bottom:12px;background:var(--body-bg, #fff)}.structured-field-editor .sf-object:not(.sf-object-root).sf-object-collapsed{padding:0}.structured-field-editor .sf-object-title{display:flex;align-items:center;gap:6px;font-weight:600;font-size:.8125rem;color:var(--body-fg, #333);text-transform:capitalize;padding:0 4px}.structured-field-editor .sf-object-title-text{flex-shrink:0}.structured-field-editor .sf-object-summary{font-weight:400;font-size:.75rem;color:var(--body-quiet-color, #888);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px}.structured-field-editor .sf-collapse-btn{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:none;background:rgba(0,0,0,0);cursor:pointer;color:var(--body-quiet-color, #888);border-radius:3px;flex-shrink:0}.structured-field-editor .sf-collapse-btn:hover{background:var(--darkened-bg, #f0f0f0);color:var(--body-fg, #333)}.structured-field-editor .sf-object-fields{display:flex;flex-direction:column}.structured-field-editor .sf-array{margin-bottom:12px}.structured-field-editor .sf-array-header{display:flex;align-items:center;gap:8px;margin-bottom:8px}.structured-field-editor .sf-array-header .sf-label{margin-bottom:0}.structured-field-editor .sf-array-collapse-toggle{margin-left:auto;display:inline-flex;align-items:center;cursor:pointer;color:var(--body-quiet-color, #aaa);line-height:0}.structured-field-editor .sf-array-collapse-toggle:hover{color:var(--body-fg, #333)}.structured-field-editor .sf-array-count{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;padding:0 6px;background:var(--darkened-bg, #f0f0f0);border-radius:10px;font-size:.6875rem;font-weight:600;color:var(--body-quiet-color, #666)}.structured-field-editor .sf-array-items{display:flex;flex-direction:column;gap:8px}.structured-field-editor .sf-array-item{border:1px solid var(--border-color, #ccc);border-radius:4px;background:var(--darkened-bg, #f8f8f8);overflow:visible;position:relative}.structured-field-editor .sf-array-item[draggable=true]{cursor:grab}.structured-field-editor .sf-array-item.sf-dragging{opacity:.4}.structured-field-editor .sf-array-item.sf-drag-over{outline:2px solid var(--primary-color, #3b82f6);outline-offset:-2px}.structured-field-editor .sf-array-item-header{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:var(--darkened-bg, #f0f0f0);border-bottom:1px solid var(--border-color, #ccc);border-radius:4px 4px 0 0}.structured-field-editor .sf-array-item-left{display:flex;align-items:center;gap:6px}.structured-field-editor .sf-drag-handle{display:inline-flex;align-items:center;color:var(--body-quiet-color, #aaa);cursor:grab;padding:0 2px;line-height:0}.structured-field-editor .sf-drag-handle:active{cursor:grabbing}.structured-field-editor .sf-drag-handle:hover{color:var(--body-fg, #555)}.structured-field-editor .sf-array-item-index{font-size:.6875rem;font-weight:600;color:var(--body-quiet-color, #666)}.structured-field-editor .sf-array-item-actions{display:flex;gap:4px}.structured-field-editor .sf-array-item-body{padding:10px}.structured-field-editor .sf-array-item-body>.sf-object.sf-object-root,.structured-field-editor .sf-array-item-body>.sf-object:not(.sf-object-root){border:none;padding:0;margin:0;background:rgba(0,0,0,0)}.structured-field-editor .sf-nullable{margin-bottom:12px}.structured-field-editor .sf-nullable-header{display:flex;align-items:center;gap:8px;margin-bottom:4px}.structured-field-editor .sf-nullable-header .sf-label{margin-bottom:0}.structured-field-editor .sf-nullable-body{padding-left:0}.structured-field-editor .sf-nullable-body:not(:empty){border:1px solid var(--border-color, #ccc);border-radius:4px;padding:12px;margin-top:4px;background:var(--body-bg, #fff)}.structured-field-editor .sf-union{margin-bottom:12px}.structured-field-editor .sf-union-body{border:1px solid var(--border-color, #ccc);border-radius:4px;padding:12px;margin-top:8px;background:var(--body-bg, #fff)}.structured-field-editor .sf-union-body>.sf-object{border:none;padding:0;margin:0;background:rgba(0,0,0,0)}.structured-field-editor .sf-relation{position:relative}.structured-field-editor .sf-relation-wrapper{display:flex;flex-direction:column;gap:6px}.structured-field-editor .sf-relation-selected{display:flex;flex-wrap:wrap;gap:6px}.structured-field-editor .sf-relation-selected:empty{display:none}.structured-field-editor .sf-relation-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:var(--darkened-bg, #f0f0f0);border:1px solid var(--border-color, #ccc);border-radius:4px;font-size:.8125rem;color:var(--body-fg, #333);max-width:100%}.structured-field-editor .sf-relation-tag-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.structured-field-editor .sf-relation-tag-remove{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:none;border-radius:50%;background:rgba(0,0,0,0);color:var(--body-quiet-color, #666);cursor:pointer;font-size:.6875rem;flex-shrink:0;transition:color .15s,background .15s}.structured-field-editor .sf-relation-tag-remove:hover{color:var(--error-fg, #ba2121);background:rgba(186,33,33,.1)}.structured-field-editor .sf-relation-search{position:relative}.structured-field-editor .sf-relation-input{padding-left:28px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:8px center;background-size:14px}.structured-field-editor .sf-relation-dropdown{position:absolute;top:100%;left:0;right:0;z-index:1000;max-height:240px;overflow-y:auto;background:var(--body-bg, #fff);border:1px solid var(--border-color, #ccc);border-top:none;border-radius:0 0 4px 4px;box-shadow:0 4px 12px rgba(0,0,0,.1)}.structured-field-editor .sf-relation-dropdown-item{padding:8px 12px;cursor:pointer;font-size:.8125rem;color:var(--body-fg, #333);transition:background .1s;border-bottom:1px solid var(--hairline-color, #eee)}.structured-field-editor .sf-relation-dropdown-item:last-child{border-bottom:none}.structured-field-editor .sf-relation-dropdown-item:hover,.structured-field-editor .sf-relation-dropdown-item.highlighted{background:var(--selected-bg, #e4e4e4)}.structured-field-editor .sf-relation-dropdown-empty{padding:12px;text-align:center;color:var(--body-quiet-color, #999);font-size:.8125rem;font-style:italic}.structured-field-editor .sf-relation-dropdown-more{padding:8px 12px;text-align:center;cursor:pointer;font-size:.75rem;color:var(--link-fg, #417690);font-weight:600;border-top:1px solid var(--hairline-color, #eee);transition:background .1s}.structured-field-editor .sf-relation-dropdown-more:hover{background:var(--darkened-bg, #f0f0f0)}
|