@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.
@@ -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;