@spaethtech/svelte-ui 0.17.1-dev.84.05ecf9b → 0.18.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.
@@ -55,6 +55,40 @@ the components use:
55
55
  <Button text="Save" variant="primary" onclick={() => save(name)} />
56
56
  ```
57
57
 
58
+ ## Localization (i18n) — opt-in
59
+
60
+ Components render their own microcopy (aria-labels, titles, "per page", "No results", month names…)
61
+ through a message registry with **English defaults**. Do nothing → identical English output. To
62
+ localize, call **`setUiI18n`** ONCE near the app root (a layout); partial `messages` merge over the
63
+ defaults, so translate a subset and the rest stays English. `locale` also feeds the date components'
64
+ `Intl.*` formatting (falls back to `navigator.language` when unset; a per-instance `locale` prop still
65
+ overrides).
66
+
67
+ ```svelte
68
+ <script>
69
+ import { setUiI18n } from "@spaethtech/svelte-ui";
70
+ import { getLocale } from "$lib/paraglide/runtime";
71
+ import { m } from "$lib/paraglide/messages";
72
+ setUiI18n({
73
+ locale: () => getLocale(), // getter ⇒ reactive; or a plain string
74
+ messages: () => ({
75
+ dismiss: m.ui_dismiss(),
76
+ selectAll: m.ui_select_all(),
77
+ rowRange: ({ start, end, total }) => m.ui_row_range({ start, end, total }), // "1–10 of 247"
78
+ removeFile: ({ name }) => m.ui_remove_file({ name }),
79
+ // …any subset of `UiMessages`
80
+ }),
81
+ });
82
+ </script>
83
+ ```
84
+
85
+ Parameterized entries are functions (`rowRange`, `removeFile`) — 1:1 with Paraglide's `m.key(params)`.
86
+ Values may be plain or getters (getters make components re-render on locale change; plain is fine for
87
+ per-navigation locale). Full key list: the `UiMessages` type; `defaultUiMessages` is the English source.
88
+ Strings already exposed as PROPS (`Select placeholder`, `EmptyState title`, `CommandPalette empty`…) you
89
+ localize directly on the component. **Authoring rule (this repo):** every new user-facing string a
90
+ component renders must go through `uiI18n().messages` + `defaultUiMessages`, never a bare literal.
91
+
58
92
  ## The two shared axes (mirror each other)
59
93
 
60
94
  - **`variant`** — color/style: `primary` `secondary` `danger` `info` `success` `warning` `ghost`
package/CLAUDE.md CHANGED
@@ -172,6 +172,17 @@ Two shared **cross-cutting axes** every sizeable/themeable component consumes (m
172
172
 
173
173
  ## Component Guidelines
174
174
 
175
+ ### Localization (i18n)
176
+
177
+ Every user-facing string a component renders itself (aria-label, title, placeholder, fixed visible text,
178
+ "per page"/"No results"/month names…) MUST go through the message registry, never a bare literal:
179
+ add a key to `UiMessages` + `defaultUiMessages` in `src/lib/i18n.ts` (English default = the exact old
180
+ literal, so output stays byte-identical), then render `i18n.messages.<key>` (`const i18n = uiI18n()`).
181
+ Parameterized strings are functions (`rowRange({start,end,total})`) — never concatenate. Date/number
182
+ formatting reads `i18n.locale` (fallback `navigator.language`; a per-instance `locale` prop overrides).
183
+ Strings already exposed as a component PROP are localized by the consumer — leave those. A hard-coded
184
+ user-facing literal in a component is a bug (it's invisible to `svelte-check` and to i18n consumers).
185
+
175
186
  ### Accessibility
176
187
 
177
188
  - All interactive components need keyboard support
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { uiI18n } from "../../i18n.js";
3
+ const i18n = uiI18n();
2
4
  import type { Snippet } from "svelte";
3
5
  import Close from "~icons/mdi/close";
4
6
  import type { Size } from "../../types/sizes.js";
@@ -143,7 +145,7 @@
143
145
  type="button"
144
146
  onclick={handleDismiss}
145
147
  onkeydown={handleKeydown}
146
- aria-label="Dismiss"
148
+ aria-label={i18n.messages.dismiss}
147
149
  class="inline-flex items-center justify-center rounded-full hover:[background-color:color-mix(in_srgb,currentColor_15%,transparent)] focus:outline-none transition-colors duration-150"
148
150
  >
149
151
  <Close class={responsiveClasses(size, iconSizeClass)} />
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { uiI18n } from "../i18n.js";
3
+ const i18n = uiI18n();
2
4
  import type { Snippet } from "svelte";
3
5
  import type { Size } from "../types/sizes.js";
4
6
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
@@ -119,7 +121,7 @@
119
121
  {#if dismissible}
120
122
  <button
121
123
  type="button"
122
- aria-label="Dismiss"
124
+ aria-label={i18n.messages.dismiss}
123
125
  onclick={close}
124
126
  class="inline-flex shrink-0 cursor-pointer items-center justify-center rounded p-0.5 opacity-80 transition-opacity hover:opacity-100 focus-visible:opacity-100 {responsiveClasses(
125
127
  size,
@@ -17,6 +17,8 @@
17
17
  </script>
18
18
 
19
19
  <script lang="ts">
20
+ import { uiI18n } from "../../i18n.js";
21
+ const i18n = uiI18n();
20
22
  import Button from "../Button.svelte";
21
23
  import Menu from "../Menu.svelte";
22
24
  import type { MenuItem } from "../../data/table/types.js";
@@ -71,7 +73,7 @@
71
73
  }
72
74
  </script>
73
75
 
74
- <nav aria-label="Breadcrumb" class={cls}>
76
+ <nav aria-label={i18n.messages.breadcrumb} class={cls}>
75
77
  <ol
76
78
  class="flex flex-wrap items-center gap-1 [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]"
77
79
  >
@@ -91,7 +93,7 @@
91
93
  <li class="inline-flex items-center">
92
94
  {#if node.kind === "ellipsis"}
93
95
  <Button
94
- title="Show more"
96
+ title={i18n.messages.showMore}
95
97
  variant="ghost"
96
98
  {size}
97
99
  aria-haspopup="menu"
@@ -48,6 +48,8 @@
48
48
  </script>
49
49
 
50
50
  <script lang="ts">
51
+ import { uiI18n } from "../../i18n.js";
52
+ const i18n = uiI18n();
51
53
  import Button from "../Button.svelte";
52
54
  import Menu from "../Menu.svelte";
53
55
  import type { Size } from "../../types/sizes.js";
@@ -225,7 +227,7 @@
225
227
  disabled={disabled || item.disabled}
226
228
  aria-haspopup="menu"
227
229
  aria-expanded={!!menuOpen[i]}
228
- aria-label="More"
230
+ aria-label={i18n.messages.more}
229
231
  bind:element={caretRefs[i]}
230
232
  onclick={() => (menuOpen[i] = !menuOpen[i])}
231
233
  >
@@ -10,6 +10,8 @@
10
10
  */
11
11
  -->
12
12
  <script lang="ts">
13
+ import { uiI18n } from "../i18n.js";
14
+ const i18n = uiI18n();
13
15
  import type { Size } from "../types/sizes.js";
14
16
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
15
17
  import { variantToken, type Variant } from "../types/variants.js";
@@ -43,7 +45,7 @@
43
45
  min,
44
46
  max,
45
47
  weekStart,
46
- locale = typeof navigator !== "undefined" ? navigator.language : "en-US",
48
+ locale = i18n.locale ?? (typeof navigator !== "undefined" ? navigator.language : "en-US"),
47
49
  size = "md",
48
50
  variant = "primary",
49
51
  class: cls = "",
@@ -191,14 +193,14 @@
191
193
  <div class="mb-1 flex items-center justify-between {sizing.head}">
192
194
  <button
193
195
  type="button"
194
- aria-label="Previous month"
196
+ aria-label={i18n.messages.prevMonth}
195
197
  class="grid {sizing.nav} place-items-center rounded-[var(--ui-border-radius)] hover:[background-color:var(--ui-color-hover)] active:[background-color:var(--ui-color-active)]"
196
198
  onclick={() => go(-1)}><IconLeft class="size-5" /></button
197
199
  >
198
200
  <span class="{sizing.label} font-medium">{monthLabel}</span>
199
201
  <button
200
202
  type="button"
201
- aria-label="Next month"
203
+ aria-label={i18n.messages.nextMonth}
202
204
  class="grid {sizing.nav} place-items-center rounded-[var(--ui-border-radius)] hover:[background-color:var(--ui-color-hover)] active:[background-color:var(--ui-color-active)]"
203
205
  onclick={() => go(1)}><IconRight class="size-5" /></button
204
206
  >
@@ -10,6 +10,8 @@
10
10
  </script>
11
11
 
12
12
  <script lang="ts" generics="T">
13
+ import { uiI18n } from "../../i18n.js";
14
+ const i18n = uiI18n();
13
15
  import Button from "../Button.svelte";
14
16
  import IconPrev from "~icons/mdi/chevron-left";
15
17
  import IconNext from "~icons/mdi/chevron-right";
@@ -106,7 +108,7 @@
106
108
  {#if controls && n > 1}
107
109
  <div class="absolute inset-y-0 left-2 flex items-center">
108
110
  <Button
109
- title="Previous"
111
+ title={i18n.messages.previous}
110
112
  variant="secondary"
111
113
  onclick={prev}
112
114
  disabled={!loop && index === 0}
@@ -117,7 +119,7 @@
117
119
  </div>
118
120
  <div class="absolute inset-y-0 right-2 flex items-center">
119
121
  <Button
120
- title="Next"
122
+ title={i18n.messages.next}
121
123
  variant="secondary"
122
124
  onclick={next}
123
125
  disabled={!loop && index === n - 1}
@@ -20,6 +20,8 @@
20
20
  </script>
21
21
 
22
22
  <script lang="ts">
23
+ import { uiI18n } from "../../i18n.js";
24
+ const i18n = uiI18n();
23
25
  import Input from "../Input.svelte";
24
26
  import Kbd from "../Kbd/Kbd.svelte";
25
27
  import IconSearch from "~icons/mdi/magnify";
@@ -143,7 +145,7 @@
143
145
  class="h-max w-full max-w-xl overflow-hidden rounded-[var(--ui-border-radius)] border shadow-2xl [background-color:var(--ui-color-background)] [border-color:var(--ui-border-color)]"
144
146
  role="dialog"
145
147
  aria-modal="true"
146
- aria-label="Command palette"
148
+ aria-label={i18n.messages.commandPalette}
147
149
  >
148
150
  <Input
149
151
  bind:value={query}
@@ -10,6 +10,8 @@
10
10
  */
11
11
  -->
12
12
  <script lang="ts" generics="T">
13
+ import { uiI18n } from "../i18n.js";
14
+ const i18n = uiI18n();
13
15
  import type { Snippet } from "svelte";
14
16
  import IconDots from "~icons/mdi/dots-vertical";
15
17
  import IconBulk from "~icons/mdi/format-list-checks";
@@ -195,9 +197,11 @@
195
197
  }
196
198
 
197
199
  const rangeText = $derived(
198
- total === 0
199
- ? "0 of 0"
200
- : `${(grid.page - 1) * grid.perPage + 1}–${Math.min(grid.page * grid.perPage, total)} of ${total}`,
200
+ i18n.messages.rowRange({
201
+ start: (grid.page - 1) * grid.perPage + 1,
202
+ end: Math.min(grid.page * grid.perPage, total),
203
+ total,
204
+ }),
201
205
  );
202
206
 
203
207
  // Invisible icon-slot sizing for the empty-state spacer. Sized to
@@ -246,7 +250,7 @@
246
250
  {#if selectable}
247
251
  <Checkbox
248
252
  {size}
249
- aria-label="Select all"
253
+ aria-label={i18n.messages.selectAll}
250
254
  checked={grid.allSelected}
251
255
  indeterminate={grid.someSelected}
252
256
  onchange={() => grid.toggleAll()}
@@ -317,8 +321,8 @@
317
321
  <Button
318
322
  variant="ghost"
319
323
  {size}
320
- aria-label="Bulk actions"
321
- title="Bulk actions"
324
+ aria-label={i18n.messages.bulkActions}
325
+ title={i18n.messages.bulkActions}
322
326
  disabled={grid.selected.size === 0}
323
327
  onclick={(e) => openMenu(e, bulkActions(grid.selected))}
324
328
  >
@@ -352,7 +356,7 @@
352
356
  {/if}
353
357
  <div class="grow self-center text-center {subtle}">
354
358
  {#if settled}
355
- No results.
359
+ {i18n.messages.noResults}.
356
360
  {:else}
357
361
  <span class="inline-flex items-center gap-2"
358
362
  ><IconLoading class="w-[1em] h-[1em] animate-spin" /> Loading…</span
@@ -381,7 +385,7 @@
381
385
  {#if selectable}
382
386
  <Checkbox
383
387
  {size}
384
- aria-label="Select row"
388
+ aria-label={i18n.messages.selectRow}
385
389
  checked={grid.isSelected(id)}
386
390
  onchange={() => grid.toggleRow(id)}
387
391
  />
@@ -402,8 +406,8 @@
402
406
  <Button
403
407
  variant="ghost"
404
408
  {size}
405
- aria-label="Move up"
406
- title="Move up"
409
+ aria-label={i18n.messages.moveUp}
410
+ title={i18n.messages.moveUp}
407
411
  disabled={rowIndex === 0}
408
412
  onclick={() => onReorder?.(row, "up")}
409
413
  >
@@ -412,8 +416,8 @@
412
416
  <Button
413
417
  variant="ghost"
414
418
  {size}
415
- aria-label="Move down"
416
- title="Move down"
419
+ aria-label={i18n.messages.moveDown}
420
+ title={i18n.messages.moveDown}
417
421
  disabled={rowIndex === rows.length - 1}
418
422
  onclick={() => onReorder?.(row, "down")}
419
423
  >
@@ -454,8 +458,8 @@
454
458
  variant="ghost"
455
459
  {size}
456
460
  disabled={!actions || actions.length === 0}
457
- aria-label="Row actions"
458
- title="Row actions"
461
+ aria-label={i18n.messages.rowActions}
462
+ title={i18n.messages.rowActions}
459
463
  onclick={(e) => openMenu(e, actions ?? [])}
460
464
  >
461
465
  {#snippet icon()}<IconDots />{/snippet}
@@ -490,7 +494,7 @@
490
494
  options={grid.perPageOptions.map((n) => ({ value: String(n), label: String(n) }))}
491
495
  onSelection={(o) => grid.setPerPage(Number(o.value))}
492
496
  />
493
- <span class={subtle}>per page</span>
497
+ <span class={subtle}>{i18n.messages.perPage}</span>
494
498
  </div>
495
499
 
496
500
  <div class="flex items-center gap-3">
@@ -13,6 +13,8 @@
13
13
  */
14
14
  -->
15
15
  <script lang="ts">
16
+ import { uiI18n } from "../i18n.js";
17
+ const i18n = uiI18n();
16
18
  import type { Size } from "../types/sizes.js";
17
19
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
18
20
  import { variantToken, type Variant } from "../types/variants.js";
@@ -57,7 +59,7 @@
57
59
  min,
58
60
  max,
59
61
  weekStart,
60
- locale = typeof navigator !== "undefined" ? navigator.language : "en-US",
62
+ locale = i18n.locale ?? (typeof navigator !== "undefined" ? navigator.language : "en-US"),
61
63
  placeholder = range ? "Select dates" : time ? "Select date & time" : "Select date",
62
64
  disabled = false,
63
65
  size = "md",
@@ -195,7 +197,7 @@
195
197
  {#if hasValue && !disabled}
196
198
  <button
197
199
  type="button"
198
- aria-label="Clear"
200
+ aria-label={i18n.messages.clear}
199
201
  tabindex="-1"
200
202
  class="grid size-5 place-items-center rounded {subtle} hover:[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]"
201
203
  onclick={(e) => {
@@ -207,7 +209,7 @@
207
209
  <button
208
210
  type="button"
209
211
  tabindex="-1"
210
- aria-label="Open calendar"
212
+ aria-label={i18n.messages.openCalendar}
211
213
  {disabled}
212
214
  class="inline-flex items-center [color:var(--ui-color-secondary)] disabled:opacity-50"
213
215
  onclick={toggle}><IconCalendar /></button
@@ -235,7 +237,7 @@
235
237
  {#if time}
236
238
  <div class="mt-2 flex flex-col gap-2 border-t pt-2 [border-color:var(--ui-border-color)]">
237
239
  <div class="flex items-center gap-3">
238
- <span class="w-10 shrink-0 text-xs {subtle}">Start</span><TimeSpinner
240
+ <span class="w-10 shrink-0 text-xs {subtle}">{i18n.messages.start}</span><TimeSpinner
239
241
  value={timeOf(rv.start)}
240
242
  {step}
241
243
  {seconds}
@@ -294,7 +296,7 @@
294
296
  <button
295
297
  type="button"
296
298
  class="rounded px-2 py-1 [color:var(--accent)] hover:underline"
297
- onclick={goToday}>Today</button
299
+ onclick={goToday}>{i18n.messages.today}</button
298
300
  >
299
301
  {#if time}<span class={subtle}>/</span><button
300
302
  type="button"
@@ -305,7 +307,7 @@
305
307
  <button
306
308
  type="button"
307
309
  class="rounded px-2 py-1 {subtle} hover:underline"
308
- onclick={() => (open = false)}>Done</button
310
+ onclick={() => (open = false)}>{i18n.messages.done}</button
309
311
  >
310
312
  </div>
311
313
  </div>
@@ -7,6 +7,8 @@
7
7
  */
8
8
  -->
9
9
  <script lang="ts">
10
+ import { uiI18n } from "../../i18n.js";
11
+ const i18n = uiI18n();
10
12
  import { onDestroy, untrack } from "svelte";
11
13
  import type { Snippet } from "svelte";
12
14
  import FieldChrome, { nextFieldId } from "../FieldChrome.svelte";
@@ -121,7 +123,7 @@
121
123
  <div
122
124
  role="button"
123
125
  tabindex={disabled ? -1 : 0}
124
- aria-label="Upload files: drag and drop or activate to browse"
126
+ aria-label={i18n.messages.uploadFiles}
125
127
  aria-describedby={describedBy}
126
128
  class="flex flex-col items-center justify-center gap-2 rounded-[var(--ui-border-radius)] border-2 border-dashed text-center transition-colors {responsiveClasses(
127
129
  size,
@@ -194,8 +196,8 @@
194
196
  {/if}
195
197
  </div>
196
198
  <Button
197
- title="Remove {file.name}"
198
- aria-label="Remove {file.name}"
199
+ title={i18n.messages.removeFile({ name: file.name })}
200
+ aria-label={i18n.messages.removeFile({ name: file.name })}
199
201
  variant="ghost"
200
202
  size="sm"
201
203
  icon={removeIcon}
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { uiI18n } from "../i18n.js";
3
+ const i18n = uiI18n();
2
4
  import { untrack } from "svelte";
3
5
  import type { Snippet } from "svelte";
4
6
  import type { Size } from "../types/sizes.js";
@@ -511,7 +513,7 @@
511
513
  onblur={handleListBlur}
512
514
  tabindex={disabled ? -1 : 0}
513
515
  role="listbox"
514
- aria-label="List of options"
516
+ aria-label={i18n.messages.listOfOptions}
515
517
  aria-busy={isLoading}
516
518
  aria-disabled={disabled}
517
519
  >
@@ -626,7 +628,7 @@
626
628
  aria-valuemin="0"
627
629
  aria-valuemax="100"
628
630
  tabindex="-1"
629
- aria-label="Scroll thumb"
631
+ aria-label={i18n.messages.scrollThumb}
630
632
  ></div>
631
633
  {/if}
632
634
  </div>
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { uiI18n } from "../i18n.js";
3
+ const i18n = uiI18n();
2
4
  import { tick } from "svelte";
3
5
  import type { Snippet } from "svelte";
4
6
  import Input from "./Input.svelte";
@@ -321,8 +323,8 @@
321
323
  tabindex={-1}
322
324
  disabled={atMin}
323
325
  onclick={() => stepBy(-1)}
324
- aria-label="Decrease"
325
- title="Decrease"
326
+ aria-label={i18n.messages.decrease}
327
+ title={i18n.messages.decrease}
326
328
  >
327
329
  {#snippet icon()}<Minus />{/snippet}
328
330
  </Button>
@@ -332,8 +334,8 @@
332
334
  tabindex={-1}
333
335
  disabled={atMax}
334
336
  onclick={() => stepBy(1)}
335
- aria-label="Increase"
336
- title="Increase"
337
+ aria-label={i18n.messages.increase}
338
+ title={i18n.messages.increase}
337
339
  >
338
340
  {#snippet icon()}<Plus />{/snippet}
339
341
  </Button>
@@ -8,6 +8,8 @@
8
8
  */
9
9
  -->
10
10
  <script lang="ts">
11
+ import { uiI18n } from "../../i18n.js";
12
+ const i18n = uiI18n();
11
13
  import Button from "../Button.svelte";
12
14
  import Select from "../Select.svelte";
13
15
  import NumberInput from "../NumberInput.svelte";
@@ -66,9 +68,11 @@
66
68
  });
67
69
 
68
70
  const rangeText = $derived(
69
- total <= 0
70
- ? "0 of 0"
71
- : `${(page - 1) * perPage + 1}–${Math.min(page * perPage, total)} of ${total}`,
71
+ i18n.messages.rowRange({
72
+ start: (page - 1) * perPage + 1,
73
+ end: Math.min(page * perPage, total),
74
+ total,
75
+ }),
72
76
  );
73
77
  const subtle = $derived(
74
78
  `${responsiveClasses(size, { sm: "text-xs", md: "text-sm", lg: "text-base" })} [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]`,
@@ -101,7 +105,7 @@
101
105
  </script>
102
106
 
103
107
  <nav
104
- aria-label="Pagination"
108
+ aria-label={i18n.messages.pagination}
105
109
  class="ui-accent flex flex-wrap items-center justify-between gap-3 border rounded-[var(--ui-border-radius)] {bandPad} {cls}"
106
110
  style="border-color: {bandBorder}; background-color: {bandBg}; {accentVar}"
107
111
  >
@@ -115,7 +119,7 @@
115
119
  options={perPageOptions.map((n) => ({ value: String(n), label: String(n) }))}
116
120
  onSelection={(o) => setPerPage(Number(o.value))}
117
121
  />
118
- <span class={subtle}>per page</span>
122
+ <span class={subtle}>{i18n.messages.perPage}</span>
119
123
  </div>
120
124
  {/if}
121
125
 
@@ -148,7 +152,7 @@
148
152
  {size}
149
153
  {disabled}
150
154
  inputClass="text-center px-1"
151
- aria-label="Page number"
155
+ aria-label={i18n.messages.pageNumber}
152
156
  onchange={commitJump}
153
157
  />
154
158
  </span>
@@ -20,6 +20,6 @@ type $$ComponentProps = {
20
20
  disabled?: boolean;
21
21
  class?: string;
22
22
  };
23
- declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "page" | "perPage">;
23
+ declare const Pagination: import("svelte").Component<$$ComponentProps, {}, "perPage" | "page">;
24
24
  type Pagination = ReturnType<typeof Pagination>;
25
25
  export default Pagination;
@@ -7,6 +7,8 @@
7
7
  */
8
8
  -->
9
9
  <script lang="ts">
10
+ import { uiI18n } from "../i18n.js";
11
+ const i18n = uiI18n();
10
12
  import { tick } from "svelte";
11
13
  import IconSearch from "~icons/mdi/magnify";
12
14
  import IconClose from "~icons/mdi/close";
@@ -167,15 +169,15 @@
167
169
  {#snippet icon()}<IconSearch class="opacity-60" />{/snippet}
168
170
  {#snippet actions()}
169
171
  {#if dataset.query}
170
- <Button variant="ghost" size="sm" aria-label="Clear" title="Clear" onclick={clear}
172
+ <Button variant="ghost" size="sm" aria-label={i18n.messages.clear} title={i18n.messages.clear} onclick={clear}
171
173
  >{#snippet icon()}<IconClose />{/snippet}</Button
172
174
  >
173
175
  {/if}
174
176
  <Button
175
177
  variant="ghost"
176
178
  size="sm"
177
- aria-label="Query help"
178
- title="Query help"
179
+ aria-label={i18n.messages.queryHelp}
180
+ title={i18n.messages.queryHelp}
179
181
  onclick={() => (helpOpen = true)}>{#snippet icon()}<IconHelp />{/snippet}</Button
180
182
  >
181
183
  {/snippet}
@@ -186,7 +188,7 @@
186
188
  <p class="mt-1 text-xs [color:var(--ui-color-error)]">{dataset.error}</p>
187
189
  {:else if dataset.dirty}
188
190
  <p class="mt-1 text-xs [color:color-mix(in_srgb,var(--ui-color-text)_50%,transparent)]">
189
- Press <kbd class="rounded border px-1 {bc}">Enter</kbd> to apply
191
+ Press <kbd class="rounded border px-1 {bc}">{i18n.messages.enter}</kbd> to apply
190
192
  </p>
191
193
  {/if}
192
194
  </div>
@@ -225,7 +227,7 @@
225
227
  </ul>
226
228
  </Popup>
227
229
 
228
- <ConfirmDialog bind:isOpen={helpOpen} title="Query syntax" showConfirm={false} showCancel={false}>
230
+ <ConfirmDialog bind:isOpen={helpOpen} title={i18n.messages.querySyntax} showConfirm={false} showCancel={false}>
229
231
  <div class="max-h-[70vh] space-y-4 overflow-y-auto text-sm [color:var(--ui-color-text)]">
230
232
  <p>
231
233
  A filter is one or more conditions, <code class="font-mono">$column operator value</code>.
@@ -233,7 +235,7 @@
233
235
  <code>()</code>.
234
236
  </p>
235
237
  <div>
236
- <h4 class="mb-1 font-semibold">Operators</h4>
238
+ <h4 class="mb-1 font-semibold">{i18n.messages.operators}</h4>
237
239
  <table class="w-full text-left">
238
240
  <tbody class="[&_td]:py-0.5 [&_td:first-child]:pr-4 [&_td:first-child]:font-mono">
239
241
  <tr><td>== !=</td><td>equals / not equals</td></tr>
@@ -246,7 +248,7 @@
246
248
  </table>
247
249
  </div>
248
250
  <div>
249
- <h4 class="mb-1 font-semibold">Casts</h4>
251
+ <h4 class="mb-1 font-semibold">{i18n.messages.casts}</h4>
250
252
  <p class="mb-1 text-xs [color:color-mix(in_srgb,var(--ui-color-text)_70%,transparent)]">
251
253
  A <code class="font-mono">|type</code> after a column or value forces its type for strict comparison
252
254
  — one cast per clause; put it on the column and/or the literals.
@@ -282,7 +284,7 @@
282
284
  </table>
283
285
  </div>
284
286
  <div>
285
- <h4 class="mb-1 font-semibold">Examples</h4>
287
+ <h4 class="mb-1 font-semibold">{i18n.messages.examples}</h4>
286
288
  <table class="w-full text-left">
287
289
  <tbody
288
290
  class="[&_td]:py-0.5 [&_td:first-child]:whitespace-nowrap [&_td:first-child]:pr-4 [&_td:first-child]:font-mono [&_td:last-child]:text-xs [&_td:last-child]:[color:color-mix(in_srgb,var(--ui-color-text)_65%,transparent)]"
@@ -305,7 +307,7 @@
305
307
  </div>
306
308
  <p class="text-xs [color:color-mix(in_srgb,var(--ui-color-text)_60%,transparent)]">
307
309
  Tip: type <kbd class="rounded border px-1 {bc}">$</kbd> for columns; enum columns suggest
308
- values. Applies on <kbd class="rounded border px-1 {bc}">Enter</kbd>.
310
+ values. Applies on <kbd class="rounded border px-1 {bc}">{i18n.messages.enter}</kbd>.
309
311
  </p>
310
312
  </div>
311
313
  </ConfirmDialog>
@@ -11,6 +11,8 @@
11
11
  </script>
12
12
 
13
13
  <script lang="ts">
14
+ import { uiI18n } from "../../i18n.js";
15
+ const i18n = uiI18n();
14
16
  import IconCheck from "~icons/mdi/check";
15
17
  import type { Variant } from "../../types/variants.js";
16
18
  import { variantToken } from "../../types/variants.js";
@@ -61,7 +63,7 @@
61
63
 
62
64
  <ol
63
65
  class="flex {isH ? 'flex-row items-start' : 'flex-col'} {cls}"
64
- aria-label="Progress"
66
+ aria-label={i18n.messages.progress}
65
67
  >
66
68
  {#each steps as step, i (i)}
67
69
  {@const s = state(i)}
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { uiI18n } from "../i18n.js";
3
+ const i18n = uiI18n();
2
4
  import ContentCopy from "~icons/mdi/content-copy";
3
5
  import type { HTMLTextareaAttributes } from "svelte/elements";
4
6
  import type { Snippet } from "svelte";
@@ -705,7 +707,7 @@
705
707
  updateScrollbar();
706
708
  }}
707
709
  role="group"
708
- aria-label="Text area with controls"
710
+ aria-label={i18n.messages.textAreaControls}
709
711
  >
710
712
  <textarea
711
713
  bind:this={textareaElement}
@@ -747,7 +749,7 @@
747
749
  role="button"
748
750
  tabindex="0"
749
751
  style="user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none;"
750
- title="Copy to clipboard"
752
+ title={i18n.messages.copyToClipboard}
751
753
  >
752
754
  <ContentCopy
753
755
  class="w-4 h-4 [color:var(--ui-color-secondary)!important] group-hover:[color:color-mix(in_srgb,var(--ui-color-secondary)_80%,black)!important] group-focus:[color:color-mix(in_srgb,currentColor_70%,transparent)!important] transition-colors"
@@ -774,7 +776,7 @@
774
776
  aria-valuemin="0"
775
777
  aria-valuemax="100"
776
778
  tabindex="-1"
777
- aria-label="Scroll thumb"
779
+ aria-label={i18n.messages.scrollThumb}
778
780
  ></div>
779
781
  {/if}
780
782
  </div>
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { uiI18n } from "../i18n.js";
3
+ const i18n = uiI18n();
2
4
  import Select from "./Select.svelte";
3
5
  import type { Size } from "../types/sizes.js";
4
6
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
@@ -40,7 +42,7 @@
40
42
  {options}
41
43
  {size}
42
44
  {variant}
43
- placeholder="Select theme..."
45
+ placeholder={i18n.messages.selectTheme}
44
46
  class="w-40"
45
47
  />
46
48
  </div>
@@ -9,6 +9,8 @@
9
9
  */
10
10
  -->
11
11
  <script lang="ts">
12
+ import { uiI18n } from "../i18n.js";
13
+ const i18n = uiI18n();
12
14
  import type { Size } from "../types/sizes.js";
13
15
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
14
16
  import { resolveStep, type StepSpec } from "../types/time.js";
@@ -128,7 +130,7 @@
128
130
  {#if hasValue && !disabled}
129
131
  <button
130
132
  type="button"
131
- aria-label="Clear"
133
+ aria-label={i18n.messages.clear}
132
134
  tabindex="-1"
133
135
  class="grid size-5 place-items-center rounded {subtle} hover:[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]"
134
136
  onclick={(e) => {
@@ -140,7 +142,7 @@
140
142
  <button
141
143
  type="button"
142
144
  tabindex="-1"
143
- aria-label="Open time range"
145
+ aria-label={i18n.messages.openTimeRange}
144
146
  {disabled}
145
147
  class="inline-flex items-center [color:var(--ui-color-secondary)] disabled:opacity-50"
146
148
  onclick={toggle}><IconClock /></button
@@ -1,4 +1,6 @@
1
1
  <script lang="ts">
2
+ import { uiI18n } from "../../i18n.js";
3
+ const i18n = uiI18n();
2
4
  /**
3
5
  * Renders the toast store's items in a fixed, top-layer stack. Mount ONCE at the app root; push with
4
6
  * `toast.*` from anywhere. Each toast is an Alert (filled) honouring its variant + size.
@@ -41,7 +43,7 @@
41
43
  >
42
44
  <button
43
45
  type="button"
44
- aria-label="Dismiss"
46
+ aria-label={i18n.messages.dismiss}
45
47
  class="shrink-0 self-center pr-2 opacity-60 hover:opacity-100"
46
48
  onclick={() => dismissToast(t.id)}><IconClose class="size-4" /></button
47
49
  >
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ /** A message: a fixed string, or a function of named params (mirrors Paraglide's `m.key(params)`). */
2
+ type Msg = string;
3
+ type MsgFn<P> = (params: P) => string;
4
+ /**
5
+ * Every user-facing string svelte-ui renders itself. Parameterized entries are functions so word order
6
+ * stays translatable. Strings already exposed as component PROPS (Select `placeholder`, EmptyState
7
+ * `title`, CommandPalette `placeholder`/`empty`, …) are localized by the consumer directly and are NOT
8
+ * here.
9
+ */
10
+ export type UiMessages = {
11
+ dismiss: Msg;
12
+ clear: Msg;
13
+ more: Msg;
14
+ showMore: Msg;
15
+ copyToClipboard: Msg;
16
+ done: Msg;
17
+ noResults: Msg;
18
+ previous: Msg;
19
+ next: Msg;
20
+ first: Msg;
21
+ last: Msg;
22
+ pagination: Msg;
23
+ pageNumber: Msg;
24
+ perPage: Msg;
25
+ rowRange: MsgFn<{
26
+ start: number;
27
+ end: number;
28
+ total: number;
29
+ }>;
30
+ breadcrumb: Msg;
31
+ scrollThumb: Msg;
32
+ progress: Msg;
33
+ commandPalette: Msg;
34
+ selectDate: Msg;
35
+ openCalendar: Msg;
36
+ prevMonth: Msg;
37
+ nextMonth: Msg;
38
+ today: Msg;
39
+ start: Msg;
40
+ dynamicDates: Msg;
41
+ openTimeRange: Msg;
42
+ bulkActions: Msg;
43
+ selectAll: Msg;
44
+ selectRow: Msg;
45
+ rowActions: Msg;
46
+ moveUp: Msg;
47
+ moveDown: Msg;
48
+ listOfOptions: Msg;
49
+ increase: Msg;
50
+ decrease: Msg;
51
+ textAreaControls: Msg;
52
+ uploadFiles: Msg;
53
+ removeFile: MsgFn<{
54
+ name: string;
55
+ }>;
56
+ queryHelp: Msg;
57
+ querySyntax: Msg;
58
+ casts: Msg;
59
+ operators: Msg;
60
+ examples: Msg;
61
+ notes: Msg;
62
+ enter: Msg;
63
+ selectTheme: Msg;
64
+ menu: Msg;
65
+ };
66
+ /** English defaults — the exact literals the components used to hard-code (no behaviour change). */
67
+ export declare const defaultUiMessages: UiMessages;
68
+ /** What a component reads. `locale` is `undefined` unless a provider sets one — Intl components then
69
+ * fall back to `navigator.language`, preserving current behaviour for no-op consumers. */
70
+ export type UiI18n = {
71
+ readonly locale: string | undefined;
72
+ readonly messages: UiMessages;
73
+ };
74
+ type Resolvable<T> = T | (() => T);
75
+ type UiI18nConfig = {
76
+ locale?: Resolvable<string | undefined>;
77
+ messages?: Resolvable<Partial<UiMessages>>;
78
+ };
79
+ /**
80
+ * Provide svelte-ui's localization. Call ONCE near the app root. Partial `messages` merge over the
81
+ * English defaults, so translating a subset leaves the rest English. Values may be getters for a
82
+ * runtime-switchable locale.
83
+ */
84
+ export declare function setUiI18n(config: UiI18nConfig): void;
85
+ /** Read the active messages + locale. Falls back to English defaults / `undefined` locale when no
86
+ * provider is present. Reads lazily, so getters passed to `setUiI18n` make components reactive. */
87
+ export declare function uiI18n(): UiI18n;
88
+ export {};
package/dist/i18n.js ADDED
@@ -0,0 +1,84 @@
1
+ // svelte-ui internal localization. Components render their own microcopy (aria-labels, titles, fixed
2
+ // visible text) through a small message registry with English defaults, and read a display `locale`
3
+ // for `Intl.*` formatting. A consumer overrides once at the app root via `setUiI18n`; do nothing and
4
+ // every string is the English default (byte-identical output — i18n is fully opt-in).
5
+ //
6
+ // Reactivity: `setUiI18n` accepts concrete values OR getter functions. Pass getters that read reactive
7
+ // state (e.g. `() => getLocale()`) and components re-render on locale change; pass plain values for a
8
+ // static (per-navigation) locale. Reads happen lazily in each component's reactive scope.
9
+ import { getContext, setContext } from "svelte";
10
+ /** English defaults — the exact literals the components used to hard-code (no behaviour change). */
11
+ export const defaultUiMessages = {
12
+ dismiss: "Dismiss",
13
+ clear: "Clear",
14
+ more: "More",
15
+ showMore: "Show more",
16
+ copyToClipboard: "Copy to clipboard",
17
+ done: "Done",
18
+ noResults: "No results",
19
+ previous: "Previous",
20
+ next: "Next",
21
+ first: "First",
22
+ last: "Last",
23
+ pagination: "Pagination",
24
+ pageNumber: "Page number",
25
+ perPage: "per page",
26
+ rowRange: ({ start, end, total }) => (total <= 0 ? "0 of 0" : `${start}–${end} of ${total}`),
27
+ breadcrumb: "Breadcrumb",
28
+ scrollThumb: "Scroll thumb",
29
+ progress: "Progress",
30
+ commandPalette: "Command palette",
31
+ selectDate: "Select date",
32
+ openCalendar: "Open calendar",
33
+ prevMonth: "Previous month",
34
+ nextMonth: "Next month",
35
+ today: "Today",
36
+ start: "Start",
37
+ dynamicDates: "Dynamic dates",
38
+ openTimeRange: "Open time range",
39
+ bulkActions: "Bulk actions",
40
+ selectAll: "Select all",
41
+ selectRow: "Select row",
42
+ rowActions: "Row actions",
43
+ moveUp: "Move up",
44
+ moveDown: "Move down",
45
+ listOfOptions: "List of options",
46
+ increase: "Increase",
47
+ decrease: "Decrease",
48
+ textAreaControls: "Text area with controls",
49
+ uploadFiles: "Upload files: drag and drop or activate to browse",
50
+ removeFile: ({ name }) => `Remove ${name}`,
51
+ queryHelp: "Query help",
52
+ querySyntax: "Query syntax",
53
+ casts: "Casts",
54
+ operators: "Operators",
55
+ examples: "Examples",
56
+ notes: "Notes",
57
+ enter: "Enter",
58
+ selectTheme: "Select theme...",
59
+ menu: "Menu",
60
+ };
61
+ const KEY = Symbol("svelte-ui-i18n");
62
+ const resolve = (v) => typeof v === "function" ? v() : v;
63
+ /**
64
+ * Provide svelte-ui's localization. Call ONCE near the app root. Partial `messages` merge over the
65
+ * English defaults, so translating a subset leaves the rest English. Values may be getters for a
66
+ * runtime-switchable locale.
67
+ */
68
+ export function setUiI18n(config) {
69
+ setContext(KEY, config);
70
+ }
71
+ /** Read the active messages + locale. Falls back to English defaults / `undefined` locale when no
72
+ * provider is present. Reads lazily, so getters passed to `setUiI18n` make components reactive. */
73
+ export function uiI18n() {
74
+ const cfg = getContext(KEY);
75
+ return {
76
+ get locale() {
77
+ return resolve(cfg?.locale);
78
+ },
79
+ get messages() {
80
+ const overrides = resolve(cfg?.messages);
81
+ return overrides ? { ...defaultUiMessages, ...overrides } : defaultUiMessages;
82
+ },
83
+ };
84
+ }
package/dist/index.d.ts CHANGED
@@ -82,6 +82,8 @@ export { anchored, computePlacement, resolveBoundary } from "./positioning/ancho
82
82
  export type { Side, Align, Boundary, PlaceOptions, Placement, AnchoredParams, } from "./positioning/anchored.js";
83
83
  export { tooltip } from "./positioning/tooltip.js";
84
84
  export type { TooltipOptions } from "./positioning/tooltip.js";
85
+ export { setUiI18n, uiI18n, defaultUiMessages } from "./i18n.js";
86
+ export type { UiMessages, UiI18n } from "./i18n.js";
85
87
  export type { Variant } from "./types/variants.js";
86
88
  export type { Size } from "./types/sizes.js";
87
89
  export type { StepSpec } from "./types/time.js";
package/dist/index.js CHANGED
@@ -78,5 +78,7 @@ export { default as Query } from "./components/Query.svelte";
78
78
  export { anchored, computePlacement, resolveBoundary } from "./positioning/anchored.js";
79
79
  // Tooltip action (use:tooltip) — themed tooltips on the `anchored` engine, no global handler.
80
80
  export { tooltip } from "./positioning/tooltip.js";
81
+ // Internal localization (opt-in) — override svelte-ui's own microcopy + Intl locale at the app root.
82
+ export { setUiI18n, uiI18n, defaultUiMessages } from "./i18n.js";
81
83
  export { BREAKPOINT_PX } from "./types/breakpoints.js";
82
84
  export { responsiveClasses, resolveScalar } from "./types/responsive.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.17.1-dev.84.05ecf9b",
3
+ "version": "0.18.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"