@marianmeres/stuic 3.179.0 → 3.181.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.
Files changed (37) hide show
  1. package/AGENTS.md +2 -2
  2. package/dist/components/ColorPicker/ColorPicker.svelte +457 -0
  3. package/dist/components/ColorPicker/ColorPicker.svelte.d.ts +75 -0
  4. package/dist/components/ColorPicker/README.md +220 -0
  5. package/dist/components/ColorPicker/color-value.d.ts +32 -0
  6. package/dist/components/ColorPicker/color-value.js +55 -0
  7. package/dist/components/ColorPicker/i18n-sk.d.ts +17 -0
  8. package/dist/components/ColorPicker/i18n-sk.js +48 -0
  9. package/dist/components/ColorPicker/i18n.d.ts +64 -0
  10. package/dist/components/ColorPicker/i18n.js +75 -0
  11. package/dist/components/ColorPicker/index.css +275 -0
  12. package/dist/components/ColorPicker/index.d.ts +4 -0
  13. package/dist/components/ColorPicker/index.js +4 -0
  14. package/dist/components/ColorPicker/palettes.d.ts +41 -0
  15. package/dist/components/ColorPicker/palettes.js +49 -0
  16. package/dist/components/FieldsBuilder/FieldsBuilder.svelte +43 -18
  17. package/dist/components/FieldsBuilder/FieldsBuilder.svelte.d.ts +15 -1
  18. package/dist/components/FieldsBuilder/README.md +65 -30
  19. package/dist/components/FieldsBuilder/_internal/OptionsEditor.svelte +10 -1
  20. package/dist/components/FieldsBuilder/_internal/OptionsEditor.svelte.d.ts +3 -0
  21. package/dist/components/FieldsBuilder/types.d.ts +7 -2
  22. package/dist/components/FieldsBuilder/utils.d.ts +13 -3
  23. package/dist/components/FieldsBuilder/utils.js +9 -13
  24. package/dist/components/Nav/Nav.svelte +7 -2
  25. package/dist/components/Nav/Nav.svelte.d.ts +7 -2
  26. package/dist/components/Nav/README.md +2 -2
  27. package/dist/components/TabbedMenu/README.md +14 -13
  28. package/dist/components/TabbedMenu/TabbedMenu.svelte +7 -2
  29. package/dist/components/TabbedMenu/TabbedMenu.svelte.d.ts +7 -2
  30. package/dist/index.css +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/utils/tr.d.ts +26 -14
  34. package/dist/utils/tr.js +43 -19
  35. package/docs/_archive/maybe-todo.md +9 -2
  36. package/docs/domains/components.md +58 -1
  37. package/package.json +1 -1
@@ -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,41 @@ 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"])`. It is the library-wide `tr()` under this
318
+ component's name (`LocalizedText` is an alias of `MaybeLocalized`), so a label resolved in
319
+ your own `preview` snippet and one resolved by `Nav` or `TabbedMenu` degrade identically.
285
320
 
286
321
  ## Accessibility
287
322
 
@@ -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. */
@@ -1,6 +1,11 @@
1
1
  import type { Snippet } from "svelte";
2
- /** A localized string: either one plain string, or a per-language map. */
3
- export type LocalizedText = string | Record<string, string>;
2
+ import type { MaybeLocalized } from "../../utils/tr.js";
3
+ /**
4
+ * A localized string: either one plain string, or a per-language map. The
5
+ * same shape as the library-wide `MaybeLocalized` (resolved by `tr()`); the
6
+ * alias is kept for this component's vocabulary.
7
+ */
8
+ export type LocalizedText = MaybeLocalized;
4
9
  /** A single option of a choice-like field type (one declaring `supportsOptions`). */
5
10
  export interface FieldOptionDef {
6
11
  /** Machine value, unique within the field's options. */
@@ -5,10 +5,13 @@ 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. This is the library-wide `tr()` under this component's name — same
12
+ * resolution, same fallbacks (a JSON-encoded record is accepted too).
10
13
  */
11
- export declare function getLocalizedText(text: LocalizedText | null | undefined, preferredLanguage?: string): string;
14
+ export declare function getLocalizedText(text: LocalizedText | null | undefined, preferredLanguage?: string | string[]): string;
12
15
  /**
13
16
  * Derive a machine key from a human label: transliterates diacritics
14
17
  * (`Ročník` → `rocnik`), lowercases, collapses everything else to `_`. A slug
@@ -48,7 +51,14 @@ export interface ValidateFieldDefsOptions {
48
51
  keyMaxLength?: number;
49
52
  reservedKeys?: string[] | ((key: string) => boolean);
50
53
  maxFields?: number;
54
+ /** The canonical language: the "label required" rule reads this entry. */
51
55
  defaultLanguage?: string;
56
+ /**
57
+ * Language the texts interpolated into messages (an extra's `label`) are
58
+ * read in, falling back to `defaultLanguage`. The rules themselves are not
59
+ * affected. Default: `defaultLanguage`.
60
+ */
61
+ displayLanguage?: string;
52
62
  /** Translator for the error messages; defaults to returning the message key. */
53
63
  t?: FieldsBuilderTranslate;
54
64
  }
@@ -1,21 +1,16 @@
1
+ import { tr } from "../../utils/tr.js";
1
2
  /** Default machine-key policy: lowercase snake_case, starts with a letter, max 63 chars. */
2
3
  export const DEFAULT_KEY_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
3
4
  export const DEFAULT_KEY_MAX_LENGTH = 63;
4
5
  /**
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.
6
+ * Read the display text of a `LocalizedText`: the string itself, the entry of
7
+ * the first preferred language (a single one, or a fallback chain in order of
8
+ * preference) that is non-empty, or the first non-empty entry as a last
9
+ * resort. This is the library-wide `tr()` under this component's name — same
10
+ * resolution, same fallbacks (a JSON-encoded record is accepted too).
7
11
  */
8
12
  export function getLocalizedText(text, preferredLanguage) {
9
- if (text == null)
10
- return "";
11
- if (typeof text === "string")
12
- return text;
13
- if (preferredLanguage && text[preferredLanguage])
14
- return text[preferredLanguage];
15
- for (const v of Object.values(text))
16
- if (v)
17
- return v;
18
- return "";
13
+ return tr(text, preferredLanguage);
19
14
  }
20
15
  /**
21
16
  * Derive a machine key from a human label: transliterates diacritics
@@ -75,6 +70,7 @@ export function validateFieldDefs(defs, opts = {}) {
75
70
  const keyPattern = opts.keyPattern ?? DEFAULT_KEY_PATTERN;
76
71
  const keyMaxLength = opts.keyMaxLength ?? DEFAULT_KEY_MAX_LENGTH;
77
72
  const typeMap = opts.types ? new Map(opts.types.map((td) => [td.type, td])) : null;
73
+ const displayLanguages = [opts.displayLanguage, opts.defaultLanguage].filter((l) => !!l);
78
74
  const rowErrors = defs.map(() => null);
79
75
  const put = (i, field, msg) => {
80
76
  rowErrors[i] ??= {};
@@ -133,7 +129,7 @@ export function validateFieldDefs(defs, opts = {}) {
133
129
  const v = d.extras?.[ex.key];
134
130
  if (typeof v === "string" && v.length > ex.maxlength) {
135
131
  put(i, "extras", t("err_extra_maxlength", {
136
- label: getLocalizedText(ex.label, opts.defaultLanguage),
132
+ label: getLocalizedText(ex.label, displayLanguages),
137
133
  max: ex.maxlength,
138
134
  }));
139
135
  }
@@ -55,8 +55,13 @@
55
55
  /** Optional section title rendered above the groups (uppercase, non-interactive) */
56
56
  title?: MaybeLocalized;
57
57
 
58
- /** Current locale for MaybeLocalized resolution */
59
- locale?: string;
58
+ /**
59
+ * Locale for `MaybeLocalized` label resolution — one locale, or a chain
60
+ * in order of preference (e.g. `[user.locale, "en"]`). A missing
61
+ * translation falls back along the chain, then to the label's first
62
+ * non-empty entry.
63
+ */
64
+ locale?: string | string[];
60
65
 
61
66
  /** Whether the sidebar is in collapsed mode (icon-only) */
62
67
  isCollapsed?: boolean;
@@ -49,8 +49,13 @@ export interface Props extends Omit<HTMLAttributes<HTMLElement>, "children" | "t
49
49
  groups: NavGroup[];
50
50
  /** Optional section title rendered above the groups (uppercase, non-interactive) */
51
51
  title?: MaybeLocalized;
52
- /** Current locale for MaybeLocalized resolution */
53
- locale?: string;
52
+ /**
53
+ * Locale for `MaybeLocalized` label resolution — one locale, or a chain
54
+ * in order of preference (e.g. `[user.locale, "en"]`). A missing
55
+ * translation falls back along the chain, then to the label's first
56
+ * non-empty entry.
57
+ */
58
+ locale?: string | string[];
54
59
  /** Whether the sidebar is in collapsed mode (icon-only) */
55
60
  isCollapsed?: boolean;
56
61
  /** Whether the sidebar is expanding (transitioning from collapsed to expanded) */
@@ -8,7 +8,7 @@ A navigation component for sidebars with support for groups, nested items, expan
8
8
  | ---------------------- | --------------------------------------------------- | ------------- | --------------------------------------------------------- |
9
9
  | `groups` | `NavGroup[]` | - | Navigation groups to render |
10
10
  | `title` | `MaybeLocalized` | - | Section title above groups (uppercase, non-interactive) |
11
- | `locale` | `string` | - | Current locale for localized labels |
11
+ | `locale` | `string \| string[]` | - | Locale (or preference chain) for localized labels |
12
12
  | `isCollapsed` | `boolean` | `false` | Collapsed mode (icon-only) |
13
13
  | `isExpanding` | `boolean` | `false` | Transitioning from collapsed to expanded |
14
14
  | `activeId` | `string` | - | Active item ID for highlighting |
@@ -329,7 +329,7 @@ Note: in icon-only sidebar mode (`isCollapsed`), the section title is visually h
329
329
  - **localStorage Persistence**: Expand/collapse state persists across page reloads
330
330
  - **Collapsed Mode**: Icon-only sidebar with tooltips
331
331
  - **Active State**: Highlight current item via `activeId` or custom callback
332
- - **Localization**: Labels support `MaybeLocalized` type
332
+ - **Localization**: Labels support `MaybeLocalized`, resolved via `tr()` against `locale` (a single locale or a preference chain); a missing translation falls back to the label's first non-empty entry, never to `[object Object]`
333
333
  - **Touch-Friendly**: Auto-detect or manually enable larger touch targets
334
334
  - **ARIA Compliant**: Proper roles and aria-expanded attributes
335
335
  - **Reduced Motion**: Respects user's reduced motion preference
@@ -22,19 +22,20 @@ A horizontal tab navigation component built on semantic `ul/li` markup with ARIA
22
22
 
23
23
  ## Props
24
24
 
25
- | Prop | Type | Default | Description |
26
- | --------------------- | -------------------------------- | ----------- | ---------------------------------- |
27
- | `items` | `TabbedMenuItem[]` | required | Array of tab items |
28
- | `value` | `string \| number` | `undefined` | Active tab id (bindable) |
29
- | `disabled` | `boolean` | `false` | Disable all tabs |
30
- | `onSelect` | `(item: TabbedMenuItem) => void` | `undefined` | Callback when tab is selected |
31
- | `class` | `string` | `undefined` | Class for the `ul` wrapper |
32
- | `classItem` | `string` | `undefined` | Class for each `li` element |
33
- | `classButton` | `string` | `undefined` | Class for tab buttons |
34
- | `classButtonActive` | `string` | `undefined` | Additional class for active tab |
35
- | `classButtonDisabled` | `string` | `undefined` | Additional class for disabled tabs |
36
- | `unstyled` | `boolean` | `false` | Skip default styling |
37
- | `el` | `HTMLUListElement` | `undefined` | Element reference (bindable) |
25
+ | Prop | Type | Default | Description |
26
+ | --------------------- | -------------------------------- | ----------- | --------------------------------------------- |
27
+ | `items` | `TabbedMenuItem[]` | required | Array of tab items |
28
+ | `value` | `string \| number` | `undefined` | Active tab id (bindable) |
29
+ | `disabled` | `boolean` | `false` | Disable all tabs |
30
+ | `onSelect` | `(item: TabbedMenuItem) => void` | `undefined` | Callback when tab is selected |
31
+ | `locale` | `string \| string[]` | `undefined` | Locale (or chain) for `MaybeLocalized` labels |
32
+ | `class` | `string` | `undefined` | Class for the `ul` wrapper |
33
+ | `classItem` | `string` | `undefined` | Class for each `li` element |
34
+ | `classButton` | `string` | `undefined` | Class for tab buttons |
35
+ | `classButtonActive` | `string` | `undefined` | Additional class for active tab |
36
+ | `classButtonDisabled` | `string` | `undefined` | Additional class for disabled tabs |
37
+ | `unstyled` | `boolean` | `false` | Skip default styling |
38
+ | `el` | `HTMLUListElement` | `undefined` | Element reference (bindable) |
38
39
 
39
40
  ## TabbedMenuItem Interface
40
41
 
@@ -21,8 +21,13 @@
21
21
  disabled?: boolean;
22
22
  onSelect?: (item: TabbedMenuItem) => void;
23
23
  orientation?: "horizontal" | "vertical";
24
- /** Current locale for MaybeLocalized resolution */
25
- locale?: string;
24
+ /**
25
+ * Locale for `MaybeLocalized` label resolution — one locale, or a chain
26
+ * in order of preference (e.g. `[user.locale, "en"]`). A missing
27
+ * translation falls back along the chain, then to the label's first
28
+ * non-empty entry.
29
+ */
30
+ locale?: string | string[];
26
31
  //
27
32
  class?: string;
28
33
  classItem?: string;
@@ -16,8 +16,13 @@ export interface Props extends Omit<HTMLAttributes<HTMLUListElement>, "children"
16
16
  disabled?: boolean;
17
17
  onSelect?: (item: TabbedMenuItem) => void;
18
18
  orientation?: "horizontal" | "vertical";
19
- /** Current locale for MaybeLocalized resolution */
20
- locale?: string;
19
+ /**
20
+ * Locale for `MaybeLocalized` label resolution — one locale, or a chain
21
+ * in order of preference (e.g. `[user.locale, "en"]`). A missing
22
+ * translation falls back along the chain, then to the label's first
23
+ * non-empty entry.
24
+ */
25
+ locale?: string | string[];
21
26
  class?: string;
22
27
  classItem?: string;
23
28
  classButton?: string;
package/dist/index.css CHANGED
@@ -86,6 +86,7 @@ In practice:
86
86
  @import "./components/RegisterForm/index.css";
87
87
  @import "./components/LoginOrRegisterForm/index.css";
88
88
  @import "./components/Checkout/index.css";
89
+ @import "./components/ColorPicker/index.css";
89
90
  @import "./components/CommandMenu/index.css";
90
91
  /* NOTE: CommentInput/index.css is deliberately NOT imported here — same reason as
91
92
  MarkdownEditor's. It is a subpath-export component and imports its own CSS
package/dist/index.d.ts CHANGED
@@ -39,6 +39,7 @@ export * from "./components/Carousel/index.js";
39
39
  export * from "./components/Checkout/index.js";
40
40
  export * from "./components/Circle/index.js";
41
41
  export * from "./components/Collapsible/index.js";
42
+ export * from "./components/ColorPicker/index.js";
42
43
  export * from "./components/ColorScheme/index.js";
43
44
  export * from "./components/CommandMenu/index.js";
44
45
  export * from "./components/ContactUsForm/index.js";
package/dist/index.js CHANGED
@@ -40,6 +40,7 @@ export * from "./components/Carousel/index.js";
40
40
  export * from "./components/Checkout/index.js";
41
41
  export * from "./components/Circle/index.js";
42
42
  export * from "./components/Collapsible/index.js";
43
+ export * from "./components/ColorPicker/index.js";
43
44
  export * from "./components/ColorScheme/index.js";
44
45
  export * from "./components/CommandMenu/index.js";
45
46
  // NOTE: CommentInput is deliberately NOT exported here. It embeds MarkdownEditor,
@@ -1,25 +1,37 @@
1
1
  /**
2
- * A value that may be a plain string or a locale-keyed object of translations.
2
+ * A value that may be a plain string or a locale-keyed object of translations
3
+ * (`{ en: "Hello", sk: "Ahoj" }`).
3
4
  */
4
5
  export type MaybeLocalized = string | Record<string, string>;
5
6
  /**
6
- * Performs a locale-based translation lookup on a value.
7
+ * Resolves a `MaybeLocalized` value to the text to display.
7
8
  *
8
- * If the value is a plain string, returns it directly. If it's an object with
9
- * locale keys (e.g., `{ en: "Hello", sk: "Ahoj" }`), returns the value for the
10
- * given locale.
9
+ * A plain string is returned as-is. A locale-keyed record (or a JSON string
10
+ * encoding one — how string-valued inputs such as `FieldInputLocalized` carry
11
+ * it) is resolved in this order:
11
12
  *
12
- * @param val - A plain string or locale-keyed object (can also be a JSON string of the object)
13
- * @param locale - The locale key to look up (e.g., "en", "sk")
14
- * @param fallback - Fallback value if locale key not found
15
- * @returns The translated string
13
+ * 1. the first entry of `locale` (one locale, or a preference chain in order)
14
+ * that is non-empty — an empty entry counts as missing,
15
+ * 2. `fallback`, when one is given,
16
+ * 3. the first non-empty entry of the record,
17
+ * 4. `""`.
18
+ *
19
+ * So a record never renders as `[object Object]`, and a missing translation
20
+ * degrades to another language rather than to nothing. `null`/`undefined`
21
+ * yield `fallback ?? ""`.
22
+ *
23
+ * @param val - A plain string, a locale-keyed record, or a JSON string of one
24
+ * @param locale - The locale to look up, or a chain of locales in order of preference
25
+ * @param fallback - Text to use when none of `locale` has a non-empty entry
16
26
  *
17
27
  * @example
18
28
  * ```ts
19
- * tr('Hello', 'sk'); // 'Hello' (plain string returned as-is)
20
- * tr({ en: 'Hello', sk: 'Ahoj' }, 'sk'); // 'Ahoj'
21
- * tr({ en: 'Hello' }, 'sk', 'fallback'); // 'fallback'
22
- * tr('{"en":"Hello","sk":"Ahoj"}', 'sk'); // 'Ahoj' (JSON string parsed)
29
+ * tr("Hello", "sk"); // "Hello" (plain string returned as-is)
30
+ * tr({ en: "Hello", sk: "Ahoj" }, "sk"); // "Ahoj"
31
+ * tr({ en: "Hello", sk: "Ahoj" }, ["cs", "sk"]); // "Ahoj" (chain, in order)
32
+ * tr({ en: "Hello" }, "sk"); // "Hello" (first non-empty entry)
33
+ * tr({ en: "Hello" }, "sk", "—"); // "—" (explicit fallback wins)
34
+ * tr('{"en":"Hello","sk":"Ahoj"}', "sk"); // "Ahoj" (JSON string parsed)
23
35
  * ```
24
36
  */
25
- export declare function tr(val: MaybeLocalized | undefined | null, locale?: string, fallback?: string): string;
37
+ export declare function tr(val: MaybeLocalized | undefined | null, locale?: string | string[], fallback?: string): string;
package/dist/utils/tr.js CHANGED
@@ -1,30 +1,54 @@
1
+ import { isPlainObject } from "./is-plain-object.js";
1
2
  import { maybeJsonParse } from "./maybe-json-parse.js";
2
3
  /**
3
- * Performs a locale-based translation lookup on a value.
4
+ * Resolves a `MaybeLocalized` value to the text to display.
4
5
  *
5
- * If the value is a plain string, returns it directly. If it's an object with
6
- * locale keys (e.g., `{ en: "Hello", sk: "Ahoj" }`), returns the value for the
7
- * given locale.
6
+ * A plain string is returned as-is. A locale-keyed record (or a JSON string
7
+ * encoding one — how string-valued inputs such as `FieldInputLocalized` carry
8
+ * it) is resolved in this order:
8
9
  *
9
- * @param val - A plain string or locale-keyed object (can also be a JSON string of the object)
10
- * @param locale - The locale key to look up (e.g., "en", "sk")
11
- * @param fallback - Fallback value if locale key not found
12
- * @returns The translated string
10
+ * 1. the first entry of `locale` (one locale, or a preference chain in order)
11
+ * that is non-empty — an empty entry counts as missing,
12
+ * 2. `fallback`, when one is given,
13
+ * 3. the first non-empty entry of the record,
14
+ * 4. `""`.
15
+ *
16
+ * So a record never renders as `[object Object]`, and a missing translation
17
+ * degrades to another language rather than to nothing. `null`/`undefined`
18
+ * yield `fallback ?? ""`.
19
+ *
20
+ * @param val - A plain string, a locale-keyed record, or a JSON string of one
21
+ * @param locale - The locale to look up, or a chain of locales in order of preference
22
+ * @param fallback - Text to use when none of `locale` has a non-empty entry
13
23
  *
14
24
  * @example
15
25
  * ```ts
16
- * tr('Hello', 'sk'); // 'Hello' (plain string returned as-is)
17
- * tr({ en: 'Hello', sk: 'Ahoj' }, 'sk'); // 'Ahoj'
18
- * tr({ en: 'Hello' }, 'sk', 'fallback'); // 'fallback'
19
- * tr('{"en":"Hello","sk":"Ahoj"}', 'sk'); // 'Ahoj' (JSON string parsed)
26
+ * tr("Hello", "sk"); // "Hello" (plain string returned as-is)
27
+ * tr({ en: "Hello", sk: "Ahoj" }, "sk"); // "Ahoj"
28
+ * tr({ en: "Hello", sk: "Ahoj" }, ["cs", "sk"]); // "Ahoj" (chain, in order)
29
+ * tr({ en: "Hello" }, "sk"); // "Hello" (first non-empty entry)
30
+ * tr({ en: "Hello" }, "sk", "—"); // "—" (explicit fallback wins)
31
+ * tr('{"en":"Hello","sk":"Ahoj"}', "sk"); // "Ahoj" (JSON string parsed)
20
32
  * ```
21
33
  */
22
34
  export function tr(val, locale, fallback) {
23
- if (!locale)
24
- return `${val ?? fallback ?? ""}`;
25
- val = maybeJsonParse(val);
26
- // if string - no translation support
27
- if (typeof val === "string")
28
- return val;
29
- return `${val?.[locale] ?? fallback ?? val ?? ""}`;
35
+ if (val == null)
36
+ return fallback ?? "";
37
+ // only a plain object is a record; a string that parses to anything else
38
+ // ("2024", "null", "[1,2]") is just that string
39
+ const parsed = maybeJsonParse(val);
40
+ if (!isPlainObject(parsed))
41
+ return String(val);
42
+ const rec = parsed;
43
+ const chain = typeof locale === "string" ? [locale] : (locale ?? []);
44
+ for (const l of chain) {
45
+ if (l && Object.hasOwn(rec, l) && rec[l])
46
+ return String(rec[l]);
47
+ }
48
+ if (fallback !== undefined)
49
+ return fallback;
50
+ for (const v of Object.values(rec))
51
+ if (v)
52
+ return String(v);
53
+ return "";
30
54
  }
@@ -83,8 +83,15 @@ Checked first, to avoid false positives:
83
83
  feedback (icon + intent swap, localized label/name, sr live announcement),
84
84
  `onCopied` / `onError` callbacks; the write is the reusable `copyToClipboard()`
85
85
  util (async Clipboard API + `execCommand` fallback) in `utils/`.
86
- 13. **ColorPicker** — beyond native `type="color"`: swatch palette + custom input.
87
- Given stuic's theming/design-tokens focus, a swatch picker would be on-brand.
86
+ 13. ~~**ColorPicker**~~ — ✅ shipped (see `src/lib/components/ColorPicker/`): a
87
+ `role="radiogroup"` swatch palette (roving tabindex, wrapping arrows, optional
88
+ "no color" swatch, `columns` grid) plus a custom row of the native
89
+ `<input type="color">` and a hex field; hidden input + `validate` with `required`,
90
+ `t` texts with Slovak bundled. Swatch values are never parsed — any CSS color
91
+ string, so `COLOR_PICKER_PALETTE_THEME` can hold `var(--stuic-color-*)` tokens and
92
+ the picked value keeps following the theme. Mobile needed no special path: the
93
+ native input opens the platform picker, so there is no popover to fight the
94
+ on-screen keyboard.
88
95
 
89
96
  ## Deliberately out of scope (or close enough to covered)
90
97