@marianmeres/stuic 3.153.0 → 3.154.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/dist/components/FieldsBuilder/FieldsBuilder.svelte +1214 -0
- package/dist/components/FieldsBuilder/FieldsBuilder.svelte.d.ts +102 -0
- package/dist/components/FieldsBuilder/README.md +248 -0
- package/dist/components/FieldsBuilder/_internal/LocalizedTextInput.svelte +229 -0
- package/dist/components/FieldsBuilder/_internal/LocalizedTextInput.svelte.d.ts +30 -0
- package/dist/components/FieldsBuilder/_internal/OptionsEditor.svelte +296 -0
- package/dist/components/FieldsBuilder/_internal/OptionsEditor.svelte.d.ts +18 -0
- package/dist/components/FieldsBuilder/i18n-sk.d.ts +22 -0
- package/dist/components/FieldsBuilder/i18n-sk.js +81 -0
- package/dist/components/FieldsBuilder/i18n.d.ts +84 -0
- package/dist/components/FieldsBuilder/i18n.js +90 -0
- package/dist/components/FieldsBuilder/index.css +187 -0
- package/dist/components/FieldsBuilder/index.d.ts +5 -0
- package/dist/components/FieldsBuilder/index.js +4 -0
- package/dist/components/FieldsBuilder/types.d.ts +76 -0
- package/dist/components/FieldsBuilder/types.js +1 -0
- package/dist/components/FieldsBuilder/utils.d.ts +66 -0
- package/dist/components/FieldsBuilder/utils.js +153 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/domains/components.md +61 -1
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { ValidateOptions, ValidationResult } from "../../actions/validate.svelte.js";
|
|
3
|
+
import type { TranslateFn } from "../../types.js";
|
|
4
|
+
import type { THC } from "../Thc/Thc.svelte";
|
|
5
|
+
import type { InputWrapClassProps } from "../Input/types.js";
|
|
6
|
+
import type { FieldDef, FieldTypeDef } from "./types.js";
|
|
7
|
+
type SnippetWithId = Snippet<[{
|
|
8
|
+
id: string;
|
|
9
|
+
}]>;
|
|
10
|
+
export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
11
|
+
/** Bindable. The ordered field-definition list. */
|
|
12
|
+
value: FieldDef[];
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* The type palette. Required on purpose — the component has no built-in
|
|
16
|
+
* notion of field types (see `DEFAULT_FIELD_TYPES` for a starter set).
|
|
17
|
+
*/
|
|
18
|
+
types: FieldTypeDef[];
|
|
19
|
+
label?: SnippetWithId | THC;
|
|
20
|
+
description?: SnippetWithId | THC;
|
|
21
|
+
class?: string;
|
|
22
|
+
id?: string;
|
|
23
|
+
tabindex?: number;
|
|
24
|
+
renderSize?: "sm" | "md" | "lg" | string;
|
|
25
|
+
/** When `true`, at least one (keyed) field is required. */
|
|
26
|
+
required?: boolean;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
|
|
29
|
+
labelAfter?: SnippetWithId | THC;
|
|
30
|
+
below?: SnippetWithId | THC;
|
|
31
|
+
labelLeft?: boolean;
|
|
32
|
+
labelLeftWidth?: "normal" | "wide";
|
|
33
|
+
labelLeftBreakpoint?: number;
|
|
34
|
+
classRow?: string;
|
|
35
|
+
classRowHeader?: string;
|
|
36
|
+
classRowBody?: string;
|
|
37
|
+
classPreview?: string;
|
|
38
|
+
style?: string;
|
|
39
|
+
/**
|
|
40
|
+
* When set, label/description/option-label editing becomes multi-language
|
|
41
|
+
* and those values may become `Record<language, string>`. Absent → plain
|
|
42
|
+
* strings.
|
|
43
|
+
*/
|
|
44
|
+
languages?: string[];
|
|
45
|
+
/** Defaults to `languages[0]`. Drives key derivation and display texts. */
|
|
46
|
+
defaultLanguage?: string;
|
|
47
|
+
languageLabels?: Record<string, string>;
|
|
48
|
+
/** Key policy. Default: lowercase snake_case starting with a letter. */
|
|
49
|
+
keyPattern?: RegExp;
|
|
50
|
+
keyMaxLength?: number;
|
|
51
|
+
reservedKeys?: string[] | ((key: string) => boolean);
|
|
52
|
+
/**
|
|
53
|
+
* Keys present in `value` when it is (re)loaded render read-only — data
|
|
54
|
+
* may already be stored under them. Keys created during the editing
|
|
55
|
+
* session stay editable. `lock.key` overrides per-field in both
|
|
56
|
+
* directions. Default `true`.
|
|
57
|
+
*/
|
|
58
|
+
keysImmutable?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Auto-derive the key from the label while the key is untouched (new
|
|
61
|
+
* fields only). Pass a function to customize the slugifier. Default `true`.
|
|
62
|
+
*/
|
|
63
|
+
deriveKeyFromLabel?: boolean | ((label: string) => string);
|
|
64
|
+
maxFields?: number;
|
|
65
|
+
/**
|
|
66
|
+
* `"mark"` (default): deleting a pre-existing field marks it (struck
|
|
67
|
+
* through, undoable) and excludes it from `value`; the row stays visible
|
|
68
|
+
* until the value is re-loaded. Fields added in this session are removed
|
|
69
|
+
* outright in both modes (there is no stored data to protect).
|
|
70
|
+
*/
|
|
71
|
+
deleteMode?: "mark" | "immediate";
|
|
72
|
+
/** Veto hook — return `false` (or throw) to cancel the delete. */
|
|
73
|
+
onBeforeDelete?: (field: FieldDef) => void | false | Promise<void | false>;
|
|
74
|
+
/**
|
|
75
|
+
* Veto hook for changing the type of a pre-existing field — return
|
|
76
|
+
* `false` (or throw) to cancel. Not called for fields added in this
|
|
77
|
+
* session.
|
|
78
|
+
*/
|
|
79
|
+
onBeforeTypeChange?: (field: FieldDef, newType: string) => void | false | Promise<void | false>;
|
|
80
|
+
onChange?: (value: FieldDef[]) => void;
|
|
81
|
+
/** Renders the preview pane. Receives the current (visible) fields. */
|
|
82
|
+
preview?: Snippet<[{
|
|
83
|
+
fields: FieldDef[];
|
|
84
|
+
}]>;
|
|
85
|
+
/**
|
|
86
|
+
* Component width at which the preview pane renders side-by-side instead
|
|
87
|
+
* of below. `0` → always below. Default `768`.
|
|
88
|
+
*/
|
|
89
|
+
previewBreakpoint?: number;
|
|
90
|
+
addLabel?: string;
|
|
91
|
+
emptyMessage?: string;
|
|
92
|
+
t?: TranslateFn;
|
|
93
|
+
}
|
|
94
|
+
declare const FieldsBuilder: import("svelte").Component<Props, {
|
|
95
|
+
validate: () => ValidationResult | undefined;
|
|
96
|
+
clearValidation: () => void;
|
|
97
|
+
getValidation: () => ValidationResult | undefined;
|
|
98
|
+
focus: () => void;
|
|
99
|
+
scrollIntoView: (opts?: ScrollIntoViewOptions) => void;
|
|
100
|
+
}, "value">;
|
|
101
|
+
type FieldsBuilder = ReturnType<typeof FieldsBuilder>;
|
|
102
|
+
export default FieldsBuilder;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# FieldsBuilder
|
|
2
|
+
|
|
3
|
+
A composite form control for **authoring an ordered list of field definitions** — the
|
|
4
|
+
"what properties does a thing have?" editor. The end user adds a field, names it, picks
|
|
5
|
+
its type, marks it required, reorders it, deletes it. The component's bindable `value`
|
|
6
|
+
is a plain `FieldDef[]`.
|
|
7
|
+
|
|
8
|
+
It is deliberately generic: the **type palette is a prop** (`types`), not a built-in
|
|
9
|
+
union. A CMS defining a content type, an issue tracker defining custom ticket fields,
|
|
10
|
+
and a form builder are all equally valid consumers. The component emits a field list —
|
|
11
|
+
**not** JSON Schema or any other schema language; compiling the list into whatever the
|
|
12
|
+
consumer's backend understands is the consumer's job.
|
|
13
|
+
|
|
14
|
+
```svelte
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import {
|
|
17
|
+
FieldsBuilder,
|
|
18
|
+
FIELDS_BUILDER_DEFAULT_TYPES,
|
|
19
|
+
type FieldDef,
|
|
20
|
+
} from "@marianmeres/stuic";
|
|
21
|
+
|
|
22
|
+
let value = $state<FieldDef[]>([]);
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<FieldsBuilder
|
|
26
|
+
bind:value
|
|
27
|
+
name="fields"
|
|
28
|
+
label="Fields"
|
|
29
|
+
types={FIELDS_BUILDER_DEFAULT_TYPES}
|
|
30
|
+
/>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## The value shape
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
type LocalizedText = string | Record<string, string>;
|
|
37
|
+
|
|
38
|
+
interface FieldDef {
|
|
39
|
+
key: string; // machine key, unique within the list
|
|
40
|
+
type: string; // one of the `types` palette entries' `type`
|
|
41
|
+
label: LocalizedText;
|
|
42
|
+
description?: LocalizedText;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
options?: { value: string; label: LocalizedText }[]; // edited for `supportsOptions` types
|
|
45
|
+
extras?: Record<string, unknown>; // per-type flags declared by the palette
|
|
46
|
+
lock?: FieldLock; // what the user may NOT change
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Order is positional — the array order **is** the order (no `order`/`weight` property).
|
|
51
|
+
|
|
52
|
+
Value membership rules:
|
|
53
|
+
|
|
54
|
+
- Defs loaded from `value` always stay in `value` (even while temporarily invalid) —
|
|
55
|
+
the component never silently drops a field.
|
|
56
|
+
- A field added in the session joins `value` once it has a key, and then stays until
|
|
57
|
+
deleted: a transiently blank key mid-rename emits the def with `key: ""` (flagged by
|
|
58
|
+
validation) rather than emitting an effective field deletion.
|
|
59
|
+
- When a field's type is changed away from a choice type, its `options` are
|
|
60
|
+
**retained** on the def (switching back restores them); consumers compiling the list
|
|
61
|
+
should ignore `options` on non-choice types.
|
|
62
|
+
- Palette `extras` defaults are materialized into `def.extras` when a field is added
|
|
63
|
+
or its type changes; a def loaded _without_ an extra's key renders unchecked — the
|
|
64
|
+
checkbox always reflects what `value` actually contains, never a phantom default.
|
|
65
|
+
|
|
66
|
+
## The palette
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
interface FieldTypeDef {
|
|
70
|
+
type: string; // stored in FieldDef.type
|
|
71
|
+
label: LocalizedText; // shown in the type picker
|
|
72
|
+
description?: LocalizedText;
|
|
73
|
+
icon?: string | Snippet; // html string or snippet, shown in the row's type chip
|
|
74
|
+
supportsOptions?: boolean; // renders the option editor
|
|
75
|
+
extras?: {
|
|
76
|
+
key: string;
|
|
77
|
+
label: LocalizedText;
|
|
78
|
+
description?: LocalizedText;
|
|
79
|
+
type: "boolean"; // v1: booleans only (a checkbox per extra)
|
|
80
|
+
default?: boolean;
|
|
81
|
+
}[];
|
|
82
|
+
preview?: Snippet<[FieldDef]>; // per-type preview of a single field
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`FIELDS_BUILDER_DEFAULT_TYPES` ships a small general-purpose palette
|
|
87
|
+
(text / longtext / number / checkbox / select / date) for demos and unopinionated
|
|
88
|
+
consumers — `types` is still a required prop, so nobody gets it by accident.
|
|
89
|
+
|
|
90
|
+
## Keys
|
|
91
|
+
|
|
92
|
+
The key is the machine identifier; once data exists under it, renaming orphans that
|
|
93
|
+
data. The component treats keys accordingly:
|
|
94
|
+
|
|
95
|
+
- **Derive-while-untouched** — typing the label "Vintage year" fills the key with
|
|
96
|
+
`vintage_year` live (diacritics transliterated: "Ročník" → `rocnik`; collisions and
|
|
97
|
+
`reservedKeys` auto-suffixed: `vintage_year_2`). The moment the user edits the key by
|
|
98
|
+
hand, derivation stops for that row and never resumes.
|
|
99
|
+
- **`keysImmutable` (default `true`)** — keys present in `value` when it is (re)loaded
|
|
100
|
+
render read-only; keys created during the session stay editable. `lock.key` overrides
|
|
101
|
+
per-field in both directions. There is deliberately **no rename affordance**; a
|
|
102
|
+
consumer that wants renaming owns the migration and passes `keysImmutable: false`.
|
|
103
|
+
- **Validation** — pattern (default `/^[a-z][a-z0-9_]{0,62}$/`), max length, uniqueness,
|
|
104
|
+
`reservedKeys`; inline per-row errors, not a submit-time surprise.
|
|
105
|
+
- The key is visually secondary: shown small and monospaced in the row header, edited
|
|
106
|
+
behind the row's "Advanced" disclosure.
|
|
107
|
+
|
|
108
|
+
## Deletion is destructive
|
|
109
|
+
|
|
110
|
+
- **`deleteMode: "mark"` (default)** — deleting a _pre-existing_ field strikes the row
|
|
111
|
+
through, excludes it from `value`, and offers Undo; the row stays visible until the
|
|
112
|
+
consumer saves and reloads. A field added in the current session is removed outright
|
|
113
|
+
(there is no stored data to protect).
|
|
114
|
+
- **`deleteMode: "immediate"`** — rows are removed outright.
|
|
115
|
+
- **`onBeforeDelete`** veto — return `false` (or throw) to cancel. This is where a
|
|
116
|
+
consumer puts its own confirm ("43 items have a value here"). No confirm dialog is
|
|
117
|
+
built in.
|
|
118
|
+
- Changing the **type** of a pre-existing field is guarded the same way:
|
|
119
|
+
`onBeforeTypeChange` veto plus a visible inline warning.
|
|
120
|
+
|
|
121
|
+
## What the component validates — and what it does not
|
|
122
|
+
|
|
123
|
+
Owns: key pattern / length / uniqueness / reserved, label non-empty, choice types have
|
|
124
|
+
at least one option with unique non-empty values, `maxFields`, `required` (at least one
|
|
125
|
+
field). `validate()` expands, scrolls to and focuses the first offender.
|
|
126
|
+
|
|
127
|
+
**Does not own:** whether the resulting list is acceptable to the consumer's backend.
|
|
128
|
+
The consumer persisting the list MUST re-validate server-side — this component is a
|
|
129
|
+
convenience, not a security boundary.
|
|
130
|
+
|
|
131
|
+
Corollary: a `FieldDef` whose `type` is not in `types` is rendered as a degraded
|
|
132
|
+
read-only row with a visible warning, round-trips through `value` untouched, and does
|
|
133
|
+
not block validation. It is never silently dropped.
|
|
134
|
+
|
|
135
|
+
## Props
|
|
136
|
+
|
|
137
|
+
| Prop | Type | Default | Description |
|
|
138
|
+
| ------------------------------------------------------------- | ---------------------------------------------------- | -------------- | ------------------------------------------------------ |
|
|
139
|
+
| `value` | `FieldDef[]` | required | Bindable ordered field list |
|
|
140
|
+
| `name` | `string` | required | Hidden-input name (form participation) |
|
|
141
|
+
| `types` | `FieldTypeDef[]` | required | The type palette |
|
|
142
|
+
| `label` | `Snippet \| THC` | — | Field label |
|
|
143
|
+
| `description` | `Snippet \| THC` | — | Help text below |
|
|
144
|
+
| `languages` | `string[]` | — | Enables multi-language label/description/option labels |
|
|
145
|
+
| `defaultLanguage` | `string` | `languages[0]` | Drives key derivation and display texts |
|
|
146
|
+
| `languageLabels` | `Record<string, string>` | — | Display names for language codes |
|
|
147
|
+
| `keyPattern` | `RegExp` | snake_case | Key validation pattern |
|
|
148
|
+
| `keyMaxLength` | `number` | `63` | Key length limit |
|
|
149
|
+
| `reservedKeys` | `string[] \| (key) => boolean` | — | Keys the user may not use |
|
|
150
|
+
| `keysImmutable` | `boolean` | `true` | Freeze keys loaded from `value` |
|
|
151
|
+
| `deriveKeyFromLabel` | `boolean \| (label) => string` | `true` | Live key derivation (custom slugifier allowed) |
|
|
152
|
+
| `maxFields` | `number` | — | Disables adding beyond the limit |
|
|
153
|
+
| `deleteMode` | `"mark" \| "immediate"` | `"mark"` | Delete UX (see above) |
|
|
154
|
+
| `onBeforeDelete` | `(field) => void \| false \| Promise<void \| false>` | — | Delete veto hook |
|
|
155
|
+
| `onBeforeTypeChange` | `(field, newType) => void \| false \| Promise<...>` | — | Type-change veto hook (pre-existing fields) |
|
|
156
|
+
| `onChange` | `(value: FieldDef[]) => void` | — | Fired after every change |
|
|
157
|
+
| `preview` | `Snippet<[{ fields: FieldDef[] }]>` | — | Preview pane content (see below) |
|
|
158
|
+
| `previewBreakpoint` | `number` | `768` | Component width for side-by-side preview; `0` = below |
|
|
159
|
+
| `required` | `boolean` | `false` | At least one field required |
|
|
160
|
+
| `validate` | `boolean \| ValidateOptions` | `true` | Validate-action options |
|
|
161
|
+
| `renderSize` | `"sm" \| "md" \| "lg"` | `"sm"` | InputWrap size |
|
|
162
|
+
| `addLabel`, `emptyMessage` | `string` | — | Text overrides |
|
|
163
|
+
| `classRow`, `classRowHeader`, `classRowBody`, `classPreview` | `string` | — | Class hooks |
|
|
164
|
+
| `t` | `TranslateFn` | built-in (en) | i18n override for all texts (see below) |
|
|
165
|
+
| `disabled`, `id`, `tabindex`, `style`, `labelLeft*`, `class*` | | | Standard `Field*`/InputWrap pass-throughs |
|
|
166
|
+
|
|
167
|
+
Imperative API (via `bind:this`), same as every `Field*`:
|
|
168
|
+
`validate()`, `clearValidation()`, `getValidation()`, `focus()`, `scrollIntoView()`.
|
|
169
|
+
|
|
170
|
+
## Preview
|
|
171
|
+
|
|
172
|
+
Pass a `preview` snippet to get a live pane the component keeps in sync (side-by-side
|
|
173
|
+
when wide, stacked below when narrow). Alternatively, palette entries may carry a
|
|
174
|
+
per-type `preview` snippet; rows without one fall back to a minimal label line. What a
|
|
175
|
+
rendered field looks like is entirely the consumer's decision — there is deliberately
|
|
176
|
+
no built-in mapping from palette types to stuic `Field*` components.
|
|
177
|
+
|
|
178
|
+
```svelte
|
|
179
|
+
<FieldsBuilder bind:value name="fields" {types}>
|
|
180
|
+
{#snippet preview({ fields })}
|
|
181
|
+
{#each fields as f}<MyFieldPreview def={f} />{/each}
|
|
182
|
+
{/snippet}
|
|
183
|
+
</FieldsBuilder>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Locks
|
|
187
|
+
|
|
188
|
+
Per-field `lock` flags: `key`, `type`, `required`, `options`, `delete`, `reorder`.
|
|
189
|
+
A `lock.reorder` field is position-pinned — it cannot be dragged and no other move may
|
|
190
|
+
change its index. **Label and description are always editable**, even on fully locked
|
|
191
|
+
fields: the consumer owns a system field's identity, the user owns what it is called.
|
|
192
|
+
|
|
193
|
+
## i18n
|
|
194
|
+
|
|
195
|
+
All UI texts go through the `t` prop. English is the built-in default; Slovak ships
|
|
196
|
+
bundled and opt-in (importing it is what pulls it into your bundle — English-only
|
|
197
|
+
consumers pay nothing).
|
|
198
|
+
|
|
199
|
+
```svelte
|
|
200
|
+
<script>
|
|
201
|
+
import {
|
|
202
|
+
FieldsBuilder,
|
|
203
|
+
createFieldsBuilderT,
|
|
204
|
+
FIELDS_BUILDER_MESSAGES_SK,
|
|
205
|
+
FIELDS_BUILDER_DEFAULT_TYPES_SK,
|
|
206
|
+
} from "@marianmeres/stuic";
|
|
207
|
+
|
|
208
|
+
const t = createFieldsBuilderT(FIELDS_BUILDER_MESSAGES_SK);
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<FieldsBuilder bind:value name="fields" types={FIELDS_BUILDER_DEFAULT_TYPES_SK} {t} />
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`createFieldsBuilderT(messages, fallbackMessages?)` falls back to
|
|
215
|
+
`FIELDS_BUILDER_MESSAGES_EN` for any key the catalog does not define, so a partial
|
|
216
|
+
catalog is fine and a raw key is never rendered — pass your own object to translate
|
|
217
|
+
into a language that is not bundled, or to override individual bundled texts:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
const t = createFieldsBuilderT({
|
|
221
|
+
...FIELDS_BUILDER_MESSAGES_SK,
|
|
222
|
+
label_label: "Otázka",
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Note the palette is separate: `types` is consumer-owned data, so translating the
|
|
227
|
+
messages alone would leave the type select in English. `FIELDS_BUILDER_DEFAULT_TYPES_SK`
|
|
228
|
+
is the Slovak twin of `FIELDS_BUILDER_DEFAULT_TYPES` (identical `type` values — the
|
|
229
|
+
stored defs are unaffected by which one you pass). A palette entry's `label` /
|
|
230
|
+
`description` also accept a per-language map (`{ en: "Text", sk: "Text" }`), resolved
|
|
231
|
+
against `defaultLanguage`.
|
|
232
|
+
|
|
233
|
+
## Accessibility
|
|
234
|
+
|
|
235
|
+
- Reorder is never drag-only: every row has Move up / Move down buttons (focus follows
|
|
236
|
+
the moved row) alongside the drag handle; moves, deletes and restores are announced
|
|
237
|
+
via a polite `aria-live` region.
|
|
238
|
+
- Rows are a `list`/`listitem` structure; the row header is a real button with
|
|
239
|
+
`aria-expanded`.
|
|
240
|
+
|
|
241
|
+
## CSS Variables
|
|
242
|
+
|
|
243
|
+
Prefix: `--stuic-fields-builder-*`
|
|
244
|
+
|
|
245
|
+
`row-border`, `row-toggle-bg-hover`, `key-text`, `chip-bg`, `chip-text`, `chip-radius`,
|
|
246
|
+
`muted-text`, `warning-text`, `error-text`, `drop-indicator-color`,
|
|
247
|
+
`drop-indicator-height`, `row-opacity-dragging`, `row-opacity-deleted`,
|
|
248
|
+
`preview-border`
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
INTERNAL to FieldsBuilder — not exported from the package.
|
|
3
|
+
|
|
4
|
+
A compact `LocalizedText` editor (plain string | Record<language, string>).
|
|
5
|
+
UX mirrors FieldInputLocalized (default-language input + a toggle revealing
|
|
6
|
+
per-language rows) and reuses its `.stuic-input-localized` CSS, but differs
|
|
7
|
+
on purpose: it operates on the live LocalizedText value (no JSON string
|
|
8
|
+
round-trip), renders no InputWrap/label chrome (it lives inside a builder
|
|
9
|
+
row), and its inputs are nameless so a wrapping <form> never receives stray
|
|
10
|
+
entries for every row.
|
|
11
|
+
|
|
12
|
+
Value shape rule (same as FieldInputLocalized's serialization): content in
|
|
13
|
+
the default language only → plain string; content in more languages →
|
|
14
|
+
record with empty entries dropped. Languages absent/single → plain string,
|
|
15
|
+
except a record arriving with foreign-language content is preserved as a
|
|
16
|
+
record (never silently drop data).
|
|
17
|
+
-->
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { autogrow } from "../../../actions/autogrow.svelte.js";
|
|
20
|
+
import { tooltip } from "../../../actions/index.js";
|
|
21
|
+
import { iconChevronUp, iconLanguages } from "../../../icons/index.js";
|
|
22
|
+
import { isPlainObject } from "../../../utils/is-plain-object.js";
|
|
23
|
+
import { twMerge } from "../../../utils/tw-merge.js";
|
|
24
|
+
import type { TranslateFn } from "../../../types.js";
|
|
25
|
+
import type { LocalizedText } from "../types.js";
|
|
26
|
+
import { getLocalizedText } from "../utils.js";
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
value?: LocalizedText;
|
|
30
|
+
languages?: string[];
|
|
31
|
+
defaultLanguage?: string;
|
|
32
|
+
languageLabels?: Record<string, string>;
|
|
33
|
+
multiline?: boolean;
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
readonly?: boolean;
|
|
37
|
+
tabindex?: number;
|
|
38
|
+
/** Extra classes for the input elements. */
|
|
39
|
+
class?: string;
|
|
40
|
+
/** Id for the default-language input (label association). */
|
|
41
|
+
id?: string;
|
|
42
|
+
ariaLabel?: string;
|
|
43
|
+
/** Applied to the default-language input. */
|
|
44
|
+
ariaInvalid?: boolean;
|
|
45
|
+
/** Applied to the default-language input. */
|
|
46
|
+
ariaDescribedby?: string;
|
|
47
|
+
/** Fired after `value` has been updated by user input. */
|
|
48
|
+
onInput?: (value: LocalizedText) => void;
|
|
49
|
+
t?: TranslateFn;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let {
|
|
53
|
+
// no fallback on purpose — the parent binds possibly-undefined values
|
|
54
|
+
// (e.g. `FieldDef.description`), which Svelte forbids combining with one
|
|
55
|
+
value = $bindable(),
|
|
56
|
+
languages,
|
|
57
|
+
defaultLanguage,
|
|
58
|
+
languageLabels,
|
|
59
|
+
multiline = false,
|
|
60
|
+
placeholder,
|
|
61
|
+
disabled = false,
|
|
62
|
+
readonly = false,
|
|
63
|
+
tabindex = 0,
|
|
64
|
+
class: classProp,
|
|
65
|
+
id,
|
|
66
|
+
ariaLabel,
|
|
67
|
+
ariaInvalid,
|
|
68
|
+
ariaDescribedby,
|
|
69
|
+
onInput,
|
|
70
|
+
t = (k: string) => k,
|
|
71
|
+
}: Props = $props();
|
|
72
|
+
|
|
73
|
+
let expanded = $state(false);
|
|
74
|
+
let rootEl: HTMLElement | undefined = $state();
|
|
75
|
+
|
|
76
|
+
const _defaultLanguage = $derived(defaultLanguage || languages?.[0] || "");
|
|
77
|
+
const _hasMultiple = $derived((languages?.length ?? 0) > 1);
|
|
78
|
+
// default first (ALWAYS, even when not listed in `languages` — same as
|
|
79
|
+
// FieldInputLocalized), rest in original order
|
|
80
|
+
const sortedLanguages = $derived([
|
|
81
|
+
...(_defaultLanguage ? [_defaultLanguage] : []),
|
|
82
|
+
...(languages ?? []).filter((l) => l !== _defaultLanguage),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
// A record value may arrive while NO languages are configured (defs authored
|
|
86
|
+
// earlier WITH languages). The single input then edits the record's first
|
|
87
|
+
// filled entry instead of an "" language key.
|
|
88
|
+
function recordTarget(rec: Record<string, string>, lang: string): string {
|
|
89
|
+
return lang || Object.keys(rec).find((k) => rec[k]?.trim()) || "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readLang(lang: string): string {
|
|
93
|
+
if (value == null) return "";
|
|
94
|
+
if (typeof value === "string") return lang === _defaultLanguage ? value : "";
|
|
95
|
+
if (!lang) return getLocalizedText(value);
|
|
96
|
+
return value[lang] ?? "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeLang(lang: string, text: string) {
|
|
100
|
+
if (isPlainObject(value)) {
|
|
101
|
+
const next: Record<string, string> = { ...(value as Record<string, string>) };
|
|
102
|
+
const target = recordTarget(next, lang);
|
|
103
|
+
if (!target) {
|
|
104
|
+
// record with no filled entries and no configured languages
|
|
105
|
+
value = text;
|
|
106
|
+
} else {
|
|
107
|
+
if (text) next[target] = text;
|
|
108
|
+
else delete next[target];
|
|
109
|
+
const filled = Object.keys(next).filter((k) => next[k]?.trim());
|
|
110
|
+
if (!filled.length) value = "";
|
|
111
|
+
else if (filled.length === 1 && filled[0] === (_defaultLanguage || target))
|
|
112
|
+
value = next[filled[0]];
|
|
113
|
+
else value = next;
|
|
114
|
+
}
|
|
115
|
+
} else if (lang === _defaultLanguage || !_hasMultiple) {
|
|
116
|
+
value = text;
|
|
117
|
+
} else if (text) {
|
|
118
|
+
const next: Record<string, string> = {};
|
|
119
|
+
const current = (value as string) ?? "";
|
|
120
|
+
if (current) next[_defaultLanguage] = current;
|
|
121
|
+
next[lang] = text;
|
|
122
|
+
value = next;
|
|
123
|
+
}
|
|
124
|
+
onInput?.(value ?? "");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Focus the default-language input (rendered first in both layouts). */
|
|
128
|
+
export function focus(): void {
|
|
129
|
+
rootEl
|
|
130
|
+
?.querySelector<HTMLInputElement | HTMLTextAreaElement>("input, textarea")
|
|
131
|
+
?.focus?.();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const INPUT_CLS = "w-full";
|
|
135
|
+
|
|
136
|
+
const TOGGLE_CLS = [
|
|
137
|
+
"toggle-btn",
|
|
138
|
+
"px-1.5 rounded block self-stretch",
|
|
139
|
+
"min-w-8",
|
|
140
|
+
"flex items-center justify-center",
|
|
141
|
+
].join(" ");
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
{#snippet langInput(lang: string, isDefault: boolean)}
|
|
145
|
+
{#if multiline}
|
|
146
|
+
<textarea
|
|
147
|
+
value={readLang(lang)}
|
|
148
|
+
oninput={(e) => writeLang(lang, e.currentTarget.value)}
|
|
149
|
+
class={twMerge(INPUT_CLS, "min-h-16", classProp)}
|
|
150
|
+
{disabled}
|
|
151
|
+
{readonly}
|
|
152
|
+
{tabindex}
|
|
153
|
+
placeholder={isDefault ? placeholder : undefined}
|
|
154
|
+
id={isDefault ? id : undefined}
|
|
155
|
+
aria-label={isDefault ? ariaLabel : languageLabels?.[lang] || lang}
|
|
156
|
+
aria-invalid={(isDefault && ariaInvalid) || undefined}
|
|
157
|
+
aria-describedby={isDefault ? ariaDescribedby : undefined}
|
|
158
|
+
use:autogrow={() => ({ enabled: true, value: readLang(lang) })}
|
|
159
|
+
></textarea>
|
|
160
|
+
{:else}
|
|
161
|
+
<input
|
|
162
|
+
type="text"
|
|
163
|
+
value={readLang(lang)}
|
|
164
|
+
oninput={(e) => writeLang(lang, e.currentTarget.value)}
|
|
165
|
+
class={twMerge(INPUT_CLS, classProp)}
|
|
166
|
+
{disabled}
|
|
167
|
+
{readonly}
|
|
168
|
+
{tabindex}
|
|
169
|
+
placeholder={isDefault ? placeholder : undefined}
|
|
170
|
+
id={isDefault ? id : undefined}
|
|
171
|
+
aria-label={isDefault ? ariaLabel : languageLabels?.[lang] || lang}
|
|
172
|
+
aria-invalid={(isDefault && ariaInvalid) || undefined}
|
|
173
|
+
aria-describedby={isDefault ? ariaDescribedby : undefined}
|
|
174
|
+
/>
|
|
175
|
+
{/if}
|
|
176
|
+
{/snippet}
|
|
177
|
+
|
|
178
|
+
<div class="stuic-input-localized w-full flex items-stretch" bind:this={rootEl}>
|
|
179
|
+
<div class="flex-1 min-w-0">
|
|
180
|
+
{#if !expanded || !_hasMultiple}
|
|
181
|
+
{@render langInput(_defaultLanguage, true)}
|
|
182
|
+
{:else}
|
|
183
|
+
<div class="expanded-wrap">
|
|
184
|
+
{#each sortedLanguages as lang, idx (lang)}
|
|
185
|
+
<div
|
|
186
|
+
class={twMerge(
|
|
187
|
+
"flex-1 flex gap-2 items-center pl-2",
|
|
188
|
+
idx > 0 && "entry-divider"
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
<div
|
|
192
|
+
class={twMerge(
|
|
193
|
+
"lang-label",
|
|
194
|
+
"shrink-0 min-w-8 flex text-sm font-medium uppercase",
|
|
195
|
+
lang === _defaultLanguage &&
|
|
196
|
+
"lang-label-default after:content-['*'] after:pl-0.5"
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
199
|
+
{languageLabels?.[lang] || lang}
|
|
200
|
+
</div>
|
|
201
|
+
<div class="flex-1 min-w-0">
|
|
202
|
+
{@render langInput(lang, lang === _defaultLanguage)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
{/each}
|
|
206
|
+
</div>
|
|
207
|
+
{/if}
|
|
208
|
+
</div>
|
|
209
|
+
{#if _hasMultiple}
|
|
210
|
+
<!-- deliberately never disabled: revealing translations is read access,
|
|
211
|
+
which disabled/readonly modes must not take away -->
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
class={TOGGLE_CLS}
|
|
215
|
+
onclick={() => (expanded = !expanded)}
|
|
216
|
+
aria-label={String(t(expanded ? "hide_translations" : "show_translations"))}
|
|
217
|
+
use:tooltip={() => ({
|
|
218
|
+
enabled: true,
|
|
219
|
+
content: t(expanded ? "hide_translations" : "show_translations"),
|
|
220
|
+
})}
|
|
221
|
+
>
|
|
222
|
+
{#if expanded}
|
|
223
|
+
{@html iconChevronUp({ size: 16 })}
|
|
224
|
+
{:else}
|
|
225
|
+
{@html iconLanguages({ size: 16 })}
|
|
226
|
+
{/if}
|
|
227
|
+
</button>
|
|
228
|
+
{/if}
|
|
229
|
+
</div>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TranslateFn } from "../../../types.js";
|
|
2
|
+
import type { LocalizedText } from "../types.js";
|
|
3
|
+
interface Props {
|
|
4
|
+
value?: LocalizedText;
|
|
5
|
+
languages?: string[];
|
|
6
|
+
defaultLanguage?: string;
|
|
7
|
+
languageLabels?: Record<string, string>;
|
|
8
|
+
multiline?: boolean;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
readonly?: boolean;
|
|
12
|
+
tabindex?: number;
|
|
13
|
+
/** Extra classes for the input elements. */
|
|
14
|
+
class?: string;
|
|
15
|
+
/** Id for the default-language input (label association). */
|
|
16
|
+
id?: string;
|
|
17
|
+
ariaLabel?: string;
|
|
18
|
+
/** Applied to the default-language input. */
|
|
19
|
+
ariaInvalid?: boolean;
|
|
20
|
+
/** Applied to the default-language input. */
|
|
21
|
+
ariaDescribedby?: string;
|
|
22
|
+
/** Fired after `value` has been updated by user input. */
|
|
23
|
+
onInput?: (value: LocalizedText) => void;
|
|
24
|
+
t?: TranslateFn;
|
|
25
|
+
}
|
|
26
|
+
declare const LocalizedTextInput: import("svelte").Component<Props, {
|
|
27
|
+
focus: () => void;
|
|
28
|
+
}, "value">;
|
|
29
|
+
type LocalizedTextInput = ReturnType<typeof LocalizedTextInput>;
|
|
30
|
+
export default LocalizedTextInput;
|