@marianmeres/stuic 3.179.0 → 3.180.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.
@@ -7,7 +7,7 @@
7
7
  import type { TranslateFn } from "../../types.js";
8
8
  import type { THC } from "../Thc/Thc.svelte";
9
9
  import type { InputWrapClassProps } from "../Input/types.js";
10
- import type { FieldDef, FieldTypeDef } from "./types.js";
10
+ import type { FieldDef, FieldTypeDef, LocalizedText } from "./types.js";
11
11
 
12
12
  type SnippetWithId = Snippet<[{ id: string }]>;
13
13
 
@@ -46,8 +46,22 @@
46
46
  * strings.
47
47
  */
48
48
  languages?: string[];
49
- /** Defaults to `languages[0]`. Drives key derivation and display texts. */
49
+ /**
50
+ * The canonical (authoring) language. Defaults to `languages[0]`. Drives
51
+ * key derivation, the "label required" rule and which input the label/
52
+ * description/option editors show collapsed. Also the display language
53
+ * unless `displayLanguage` is set.
54
+ */
50
55
  defaultLanguage?: string;
56
+ /**
57
+ * Which translation of consumer-supplied localized data is DISPLAYED —
58
+ * field labels in the row list, announcements and the preview fallback;
59
+ * palette labels/descriptions; extras labels/placeholders/descriptions.
60
+ * Typically the current user's UI locale. Purely presentational: authoring
61
+ * stays on `defaultLanguage` (see above), which is also the fallback for a
62
+ * missing translation. Default: `defaultLanguage`.
63
+ */
64
+ displayLanguage?: string;
51
65
  languageLabels?: Record<string, string>;
52
66
  /** Key policy. Default: lowercase snake_case starting with a letter. */
53
67
  keyPattern?: RegExp;
@@ -165,6 +179,7 @@
165
179
  style,
166
180
  languages,
167
181
  defaultLanguage,
182
+ displayLanguage,
168
183
  languageLabels,
169
184
  keyPattern,
170
185
  keyMaxLength = DEFAULT_KEY_MAX_LENGTH,
@@ -244,6 +259,17 @@
244
259
  let rows: Row[] = $state(fromValue(value ?? []));
245
260
 
246
261
  const _defaultLanguage = $derived(defaultLanguage || languages?.[0]);
262
+ const _displayLanguage = $derived(displayLanguage || _defaultLanguage);
263
+ // display fallback chain: preferred → canonical → first non-empty entry
264
+ const _displayLanguages = $derived(
265
+ [_displayLanguage, _defaultLanguage].filter((l): l is string => !!l)
266
+ );
267
+
268
+ /** Read-only rendering of consumer-supplied localized data. */
269
+ function displayText(text: LocalizedText | null | undefined): string {
270
+ return getLocalizedText(text, _displayLanguages);
271
+ }
272
+
247
273
  const typeByName = $derived(new Map(types.map((td) => [td.type, td])));
248
274
  const visibleRows = $derived(rows.filter((r) => !r.deleted));
249
275
  const maxReached = $derived(!!maxFields && visibleRows.length >= maxFields);
@@ -569,7 +595,7 @@
569
595
  }
570
596
 
571
597
  function rowLabel(row: Row): string {
572
- return getLocalizedText(row.def.label, _defaultLanguage) || String(t("untitled"));
598
+ return displayText(row.def.label) || String(t("untitled"));
573
599
  }
574
600
 
575
601
  // clear-then-set so REPEATED identical announcements (e.g. pressing "Move
@@ -597,6 +623,7 @@
597
623
  reservedKeys,
598
624
  maxFields,
599
625
  defaultLanguage: _defaultLanguage,
626
+ displayLanguage: _displayLanguage,
600
627
  t: tUtils,
601
628
  }
602
629
  )
@@ -831,7 +858,7 @@
831
858
  {:else if entry.icon}
832
859
  {@render entry.icon()}
833
860
  {/if}
834
- {getLocalizedText(entry.label, _defaultLanguage)}
861
+ {displayText(entry.label)}
835
862
  </span>
836
863
  {/if}
837
864
  {#if (showLabelError || showKeyError || showOptionsError || showExtrasError) && !row.deleted}
@@ -994,7 +1021,7 @@
994
1021
  >
995
1022
  {#each types as td (td.type)}
996
1023
  <option value={td.type}>
997
- {getLocalizedText(td.label, _defaultLanguage)}
1024
+ {displayText(td.label)}
998
1025
  </option>
999
1026
  {/each}
1000
1027
  </select>
@@ -1016,7 +1043,7 @@
1016
1043
  </div>
1017
1044
  {#if entry.description}
1018
1045
  <div class="fb-hint text-xs mt-0.5">
1019
- {getLocalizedText(entry.description, _defaultLanguage)}
1046
+ {displayText(entry.description)}
1020
1047
  </div>
1021
1048
  {/if}
1022
1049
  {#if typeChanged(row)}
@@ -1043,6 +1070,7 @@
1043
1070
  bind:options={row.def.options}
1044
1071
  {languages}
1045
1072
  defaultLanguage={_defaultLanguage}
1073
+ displayLanguage={_displayLanguage}
1046
1074
  {languageLabels}
1047
1075
  {disabled}
1048
1076
  locked={!!row.def.lock?.options}
@@ -1077,7 +1105,7 @@
1077
1105
  {@const exValue = extraText(row, ex.key)}
1078
1106
  <div class="fb-extra">
1079
1107
  <label class="fb-sub-label" for={exId}>
1080
- {getLocalizedText(ex.label, _defaultLanguage)}
1108
+ {displayText(ex.label)}
1081
1109
  </label>
1082
1110
  {#if ex.type === "string"}
1083
1111
  <input
@@ -1086,10 +1114,7 @@
1086
1114
  class={twMerge(INPUT_CLS, "fb-extra-input w-full")}
1087
1115
  value={exValue}
1088
1116
  maxlength={ex.maxlength}
1089
- placeholder={getLocalizedText(
1090
- ex.placeholder,
1091
- _defaultLanguage
1092
- ) || undefined}
1117
+ placeholder={displayText(ex.placeholder) || undefined}
1093
1118
  oninput={(e) =>
1094
1119
  onExtraStringInput(
1095
1120
  row,
@@ -1124,11 +1149,11 @@
1124
1149
  {tabindex}
1125
1150
  >
1126
1151
  <option value="">
1127
- {getLocalizedText(ex.placeholder, _defaultLanguage)}
1152
+ {displayText(ex.placeholder)}
1128
1153
  </option>
1129
1154
  {#each ex.options as opt (opt.value)}
1130
1155
  <option value={opt.value}>
1131
- {getLocalizedText(opt.label, _defaultLanguage)}
1156
+ {displayText(opt.label)}
1132
1157
  </option>
1133
1158
  {/each}
1134
1159
  <!-- a stored value outside the declared list stays
@@ -1141,7 +1166,7 @@
1141
1166
  {/if}
1142
1167
  {#if ex.description}
1143
1168
  <div class="fb-hint text-xs mt-0.5">
1144
- {getLocalizedText(ex.description, _defaultLanguage)}
1169
+ {displayText(ex.description)}
1145
1170
  </div>
1146
1171
  {/if}
1147
1172
  </div>
@@ -1158,10 +1183,10 @@
1158
1183
  {tabindex}
1159
1184
  />
1160
1185
  <span class="text-sm">
1161
- {getLocalizedText(ex.label, _defaultLanguage)}
1186
+ {displayText(ex.label)}
1162
1187
  {#if ex.description}
1163
1188
  <span class="fb-hint block text-xs">
1164
- {getLocalizedText(ex.description, _defaultLanguage)}
1189
+ {displayText(ex.description)}
1165
1190
  </span>
1166
1191
  {/if}
1167
1192
  </span>
@@ -1291,14 +1316,14 @@
1291
1316
  {:else}
1292
1317
  <div class="fb-preview-fallback flex items-center gap-1.5 text-sm">
1293
1318
  <span>
1294
- {getLocalizedText(f.label, _defaultLanguage) || t("untitled")}
1319
+ {displayText(f.label) || t("untitled")}
1295
1320
  </span>
1296
1321
  {#if f.required}
1297
1322
  <span class="fb-row-required" aria-hidden="true">*</span>
1298
1323
  {/if}
1299
1324
  {#if pentry}
1300
1325
  <span class="fb-chip">
1301
- {getLocalizedText(pentry.label, _defaultLanguage)}
1326
+ {displayText(pentry.label)}
1302
1327
  </span>
1303
1328
  {/if}
1304
1329
  </div>
@@ -42,8 +42,22 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
42
42
  * strings.
43
43
  */
44
44
  languages?: string[];
45
- /** Defaults to `languages[0]`. Drives key derivation and display texts. */
45
+ /**
46
+ * The canonical (authoring) language. Defaults to `languages[0]`. Drives
47
+ * key derivation, the "label required" rule and which input the label/
48
+ * description/option editors show collapsed. Also the display language
49
+ * unless `displayLanguage` is set.
50
+ */
46
51
  defaultLanguage?: string;
52
+ /**
53
+ * Which translation of consumer-supplied localized data is DISPLAYED —
54
+ * field labels in the row list, announcements and the preview fallback;
55
+ * palette labels/descriptions; extras labels/placeholders/descriptions.
56
+ * Typically the current user's UI locale. Purely presentational: authoring
57
+ * stays on `defaultLanguage` (see above), which is also the fallback for a
58
+ * missing translation. Default: `defaultLanguage`.
59
+ */
60
+ displayLanguage?: string;
47
61
  languageLabels?: Record<string, string>;
48
62
  /** Key policy. Default: lowercase snake_case starting with a letter. */
49
63
  keyPattern?: RegExp;
@@ -187,35 +187,36 @@ not block validation. It is never silently dropped.
187
187
 
188
188
  ## Props
189
189
 
190
- | Prop | Type | Default | Description |
191
- | ------------------------------------------------------------- | ---------------------------------------------------- | -------------- | ------------------------------------------------------ |
192
- | `value` | `FieldDef[]` | required | Bindable ordered field list |
193
- | `name` | `string` | required | Hidden-input name (form participation) |
194
- | `types` | `FieldTypeDef[]` | required | The type palette |
195
- | `label` | `Snippet \| THC` | — | Field label |
196
- | `description` | `Snippet \| THC` | — | Help text below |
197
- | `languages` | `string[]` | — | Enables multi-language label/description/option labels |
198
- | `defaultLanguage` | `string` | `languages[0]` | Drives key derivation and display texts |
199
- | `languageLabels` | `Record<string, string>` | | Display names for language codes |
200
- | `keyPattern` | `RegExp` | snake_case | Key validation pattern |
201
- | `keyMaxLength` | `number` | `63` | Key length limit |
202
- | `reservedKeys` | `string[] \| (key) => boolean` | | Keys the user may not use |
203
- | `keysImmutable` | `boolean` | `true` | Freeze keys loaded from `value` |
204
- | `deriveKeyFromLabel` | `boolean \| (label) => string` | `true` | Live key derivation (custom slugifier allowed) |
205
- | `maxFields` | `number` | | Disables adding beyond the limit |
206
- | `deleteMode` | `"mark" \| "immediate"` | `"mark"` | Delete UX (see above) |
207
- | `onBeforeDelete` | `(field) => void \| false \| Promise<void \| false>` | | Delete veto hook |
208
- | `onBeforeTypeChange` | `(field, newType) => void \| false \| Promise<...>` | — | Type-change veto hook (pre-existing fields) |
209
- | `onChange` | `(value: FieldDef[]) => void` | — | Fired after every change |
210
- | `preview` | `Snippet<[{ fields: FieldDef[] }]>` | — | Preview pane content (see below) |
211
- | `previewBreakpoint` | `number` | `768` | Component width for side-by-side preview; `0` = below |
212
- | `required` | `boolean` | `false` | At least one field required |
213
- | `validate` | `boolean \| ValidateOptions` | `true` | Validate-action options |
214
- | `renderSize` | `"sm" \| "md" \| "lg"` | `"sm"` | InputWrap size |
215
- | `addLabel`, `emptyMessage` | `string` | — | Text overrides |
216
- | `classRow`, `classRowHeader`, `classRowBody`, `classPreview` | `string` | — | Class hooks |
217
- | `t` | `TranslateFn` | built-in (en) | i18n override for all texts (see below) |
218
- | `disabled`, `id`, `tabindex`, `style`, `labelLeft*`, `class*` | | | Standard `Field*`/InputWrap pass-throughs |
190
+ | Prop | Type | Default | Description |
191
+ | ------------------------------------------------------------- | ---------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------- |
192
+ | `value` | `FieldDef[]` | required | Bindable ordered field list |
193
+ | `name` | `string` | required | Hidden-input name (form participation) |
194
+ | `types` | `FieldTypeDef[]` | required | The type palette |
195
+ | `label` | `Snippet \| THC` | — | Field label |
196
+ | `description` | `Snippet \| THC` | — | Help text below |
197
+ | `languages` | `string[]` | — | Enables multi-language label/description/option labels |
198
+ | `defaultLanguage` | `string` | `languages[0]` | Canonical language: key derivation, required-label rule, editors' primary input |
199
+ | `displayLanguage` | `string` | `defaultLanguage` | Translation shown in read-only spots (row list, palette, extras); see i18n |
200
+ | `languageLabels` | `Record<string, string>` | | Display names for language codes |
201
+ | `keyPattern` | `RegExp` | snake_case | Key validation pattern |
202
+ | `keyMaxLength` | `number` | `63` | Key length limit |
203
+ | `reservedKeys` | `string[] \| (key) => boolean` | | Keys the user may not use |
204
+ | `keysImmutable` | `boolean` | `true` | Freeze keys loaded from `value` |
205
+ | `deriveKeyFromLabel` | `boolean \| (label) => string` | `true` | Live key derivation (custom slugifier allowed) |
206
+ | `maxFields` | `number` | | Disables adding beyond the limit |
207
+ | `deleteMode` | `"mark" \| "immediate"` | `"mark"` | Delete UX (see above) |
208
+ | `onBeforeDelete` | `(field) => void \| false \| Promise<void \| false>` | — | Delete veto hook |
209
+ | `onBeforeTypeChange` | `(field, newType) => void \| false \| Promise<...>` | — | Type-change veto hook (pre-existing fields) |
210
+ | `onChange` | `(value: FieldDef[]) => void` | — | Fired after every change |
211
+ | `preview` | `Snippet<[{ fields: FieldDef[] }]>` | — | Preview pane content (see below) |
212
+ | `previewBreakpoint` | `number` | `768` | Component width for side-by-side preview; `0` = below |
213
+ | `required` | `boolean` | `false` | At least one field required |
214
+ | `validate` | `boolean \| ValidateOptions` | `true` | Validate-action options |
215
+ | `renderSize` | `"sm" \| "md" \| "lg"` | `"sm"` | InputWrap size |
216
+ | `addLabel`, `emptyMessage` | `string` | — | Text overrides |
217
+ | `classRow`, `classRowHeader`, `classRowBody`, `classPreview` | `string` | | Class hooks |
218
+ | `t` | `TranslateFn` | built-in (en) | i18n override for all texts (see below) |
219
+ | `disabled`, `id`, `tabindex`, `style`, `labelLeft*`, `class*` | | | Standard `Field*`/InputWrap pass-throughs |
219
220
 
220
221
  Imperative API (via `bind:this`), same as every `Field*`:
221
222
  `validate()`, `clearValidation()`, `getValidation()`, `focus()`, `scrollIntoView()`.
@@ -281,7 +282,39 @@ messages alone would leave the type select in English. `FIELDS_BUILDER_DEFAULT_T
281
282
  is the Slovak twin of `FIELDS_BUILDER_DEFAULT_TYPES` (identical `type` values — the
282
283
  stored defs are unaffected by which one you pass). A palette entry's `label` /
283
284
  `description` also accept a per-language map (`{ en: "Text", sk: "Text" }`), resolved
284
- against `defaultLanguage`.
285
+ against `displayLanguage`.
286
+
287
+ ### Display language vs. default language
288
+
289
+ With `languages` set, two languages play different roles:
290
+
291
+ - **`defaultLanguage`** (default `languages[0]`) is the _canonical_ one: the key is
292
+ derived from its label, the "label required" rule checks its entry, and the
293
+ label/description/option editors open on it (the other translations sit behind the
294
+ toggle).
295
+ - **`displayLanguage`** (default `defaultLanguage`) is what the component _shows_
296
+ wherever it renders localized data read-only: the collapsed row titles and the
297
+ reorder/delete announcements, the type chips and the type picker, the palette
298
+ descriptions, extras labels/placeholders/descriptions, the built-in preview fallback,
299
+ and the extra's name in `err_extra_maxlength`. A missing translation falls back to
300
+ `defaultLanguage`, then to the first non-empty entry.
301
+
302
+ So a Slovak user of a schema whose canonical language is English gets a Slovak list and
303
+ Slovak chrome (`t`), and still edits the canonical English label first:
304
+
305
+ ```svelte
306
+ <FieldsBuilder
307
+ bind:value
308
+ name="fields"
309
+ types={FIELDS_BUILDER_DEFAULT_TYPES_SK}
310
+ languages={["en", "sk"]}
311
+ displayLanguage={user.locale}
312
+ t={createFieldsBuilderT(FIELDS_BUILDER_MESSAGES_SK)}
313
+ />
314
+ ```
315
+
316
+ `getLocalizedText(text, preferred)` — exported — accepts the same fallback chain as an
317
+ array: `getLocalizedText(label, ["sk", "en"])`.
285
318
 
286
319
  ## Accessibility
287
320
 
@@ -37,7 +37,10 @@
37
37
  interface Props {
38
38
  options?: FieldOptionDef[];
39
39
  languages?: string[];
40
+ /** Canonical language: value derivation and the editors' collapsed input. */
40
41
  defaultLanguage?: string;
42
+ /** Language the announcements read option labels in. Default: `defaultLanguage`. */
43
+ displayLanguage?: string;
41
44
  languageLabels?: Record<string, string>;
42
45
  disabled?: boolean;
43
46
  /** `lock.options` — render the list read-only. */
@@ -52,6 +55,7 @@
52
55
  options = $bindable(),
53
56
  languages,
54
57
  defaultLanguage,
58
+ displayLanguage,
55
59
  languageLabels,
56
60
  disabled = false,
57
61
  locked = false,
@@ -61,6 +65,11 @@
61
65
  }: Props = $props();
62
66
 
63
67
  const _defaultLanguage = $derived(defaultLanguage || languages?.[0]);
68
+ const _displayLanguages = $derived(
69
+ [displayLanguage || _defaultLanguage, _defaultLanguage].filter(
70
+ (l): l is string => !!l
71
+ )
72
+ );
64
73
 
65
74
  interface OptionMeta {
66
75
  /** Stable render id (options themselves have no identity). */
@@ -92,7 +101,7 @@
92
101
  ) {
93
102
  liveAnnouncement = "";
94
103
  const msg = String(
95
- t(key, { label: getLocalizedText(option.label, _defaultLanguage), ...values })
104
+ t(key, { label: getLocalizedText(option.label, _displayLanguages), ...values })
96
105
  );
97
106
  tick().then(() => (liveAnnouncement = msg));
98
107
  }
@@ -3,7 +3,10 @@ import type { FieldOptionDef } from "../types.js";
3
3
  interface Props {
4
4
  options?: FieldOptionDef[];
5
5
  languages?: string[];
6
+ /** Canonical language: value derivation and the editors' collapsed input. */
6
7
  defaultLanguage?: string;
8
+ /** Language the announcements read option labels in. Default: `defaultLanguage`. */
9
+ displayLanguage?: string;
7
10
  languageLabels?: Record<string, string>;
8
11
  disabled?: boolean;
9
12
  /** `lock.options` — render the list read-only. */
@@ -5,10 +5,12 @@ export declare const DEFAULT_KEY_MAX_LENGTH = 63;
5
5
  /** Minimal translate signature the pure helpers below need. */
6
6
  export type FieldsBuilderTranslate = (key: string, values?: Record<string, string | number>) => string;
7
7
  /**
8
- * Read the display text of a `LocalizedText`: the string itself, the preferred
9
- * language's entry, or the first non-empty entry as a fallback.
8
+ * Read the display text of a `LocalizedText`: the string itself, the entry of
9
+ * the first preferred language (a single one, or a fallback chain in order of
10
+ * preference) that is non-empty, or the first non-empty entry as a last
11
+ * resort.
10
12
  */
11
- export declare function getLocalizedText(text: LocalizedText | null | undefined, preferredLanguage?: string): string;
13
+ export declare function getLocalizedText(text: LocalizedText | null | undefined, preferredLanguage?: string | string[]): string;
12
14
  /**
13
15
  * Derive a machine key from a human label: transliterates diacritics
14
16
  * (`Ročník` → `rocnik`), lowercases, collapses everything else to `_`. A slug
@@ -48,7 +50,14 @@ export interface ValidateFieldDefsOptions {
48
50
  keyMaxLength?: number;
49
51
  reservedKeys?: string[] | ((key: string) => boolean);
50
52
  maxFields?: number;
53
+ /** The canonical language: the "label required" rule reads this entry. */
51
54
  defaultLanguage?: string;
55
+ /**
56
+ * Language the texts interpolated into messages (an extra's `label`) are
57
+ * read in, falling back to `defaultLanguage`. The rules themselves are not
58
+ * affected. Default: `defaultLanguage`.
59
+ */
60
+ displayLanguage?: string;
52
61
  /** Translator for the error messages; defaults to returning the message key. */
53
62
  t?: FieldsBuilderTranslate;
54
63
  }
@@ -2,16 +2,22 @@
2
2
  export const DEFAULT_KEY_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
3
3
  export const DEFAULT_KEY_MAX_LENGTH = 63;
4
4
  /**
5
- * Read the display text of a `LocalizedText`: the string itself, the preferred
6
- * language's entry, or the first non-empty entry as a fallback.
5
+ * Read the display text of a `LocalizedText`: the string itself, the entry of
6
+ * the first preferred language (a single one, or a fallback chain in order of
7
+ * preference) that is non-empty, or the first non-empty entry as a last
8
+ * resort.
7
9
  */
8
10
  export function getLocalizedText(text, preferredLanguage) {
9
11
  if (text == null)
10
12
  return "";
11
13
  if (typeof text === "string")
12
14
  return text;
13
- if (preferredLanguage && text[preferredLanguage])
14
- return text[preferredLanguage];
15
+ const preferred = typeof preferredLanguage === "string"
16
+ ? [preferredLanguage]
17
+ : (preferredLanguage ?? []);
18
+ for (const lang of preferred)
19
+ if (lang && text[lang])
20
+ return text[lang];
15
21
  for (const v of Object.values(text))
16
22
  if (v)
17
23
  return v;
@@ -75,6 +81,7 @@ export function validateFieldDefs(defs, opts = {}) {
75
81
  const keyPattern = opts.keyPattern ?? DEFAULT_KEY_PATTERN;
76
82
  const keyMaxLength = opts.keyMaxLength ?? DEFAULT_KEY_MAX_LENGTH;
77
83
  const typeMap = opts.types ? new Map(opts.types.map((td) => [td.type, td])) : null;
84
+ const displayLanguages = [opts.displayLanguage, opts.defaultLanguage].filter((l) => !!l);
78
85
  const rowErrors = defs.map(() => null);
79
86
  const put = (i, field, msg) => {
80
87
  rowErrors[i] ??= {};
@@ -133,7 +140,7 @@ export function validateFieldDefs(defs, opts = {}) {
133
140
  const v = d.extras?.[ex.key];
134
141
  if (typeof v === "string" && v.length > ex.maxlength) {
135
142
  put(i, "extras", t("err_extra_maxlength", {
136
- label: getLocalizedText(ex.label, opts.defaultLanguage),
143
+ label: getLocalizedText(ex.label, displayLanguages),
137
144
  max: ex.maxlength,
138
145
  }));
139
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.179.0",
3
+ "version": "3.180.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",