@spaethtech/svelte-ui 0.21.1-dev.124.7b5c577 → 0.21.1-dev.126.3149ff9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -148,12 +148,27 @@ for just the library artifact.
148
148
  **IMPORTANT**: Drive the running demo site when working on UI components — verify behavior in a real
149
149
  browser, don't rely on `svelte-check` alone.
150
150
 
151
- Development server runs at `http://localhost:5173/` (`npm run dev`).
151
+ **The dev stack is ALWAYS running — you never cold-build the demo.** A Docker container
152
+ (`svelte-ui-demo-1`, `node:24`, cmd `npm run dev:linked`) runs the Vite dev server **continuously
153
+ with HMR**, bind-mounting this repo (`/home/rspaeth/svelte-ui -> /svelte-ui`), so every edit you save
154
+ here hot-reloads into the live site within seconds. So "verify in a browser" needs **no build and no
155
+ memory** on your part — the compile already happened and stays warm. (This sandbox is small — ~1.9 GB
156
+ RAM, 1 vCPU — so a *second*, cold `npm run dev`/`vite build` in here can OOM; that's why you use the
157
+ already-running container, not a fresh build. `npm run package` is light and is fine to run.)
158
+
159
+ Reach it without `localhost` publishing: the container exposes `5173` on the Docker network (not a
160
+ host port). Get its IP with `docker inspect svelte-ui-demo-1 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'`
161
+ (e.g. `http://172.18.0.2:5173/<route>`), or go through Caddy at `https://svelte-ui.spaethtech.dev`
162
+ (`svelte-ui-caddy-1` proxies to it; `php-svelte.spaethtech.dev` → the host `:5173`). `curl` the route
163
+ first to confirm it serves, then point a browser at it.
152
164
 
153
165
  Use the **`/playwright-cli`** workflow for browser-driven testing (navigation, snapshots/screenshots,
154
- console inspection, interaction). Point it at the dev server URL and the relevant component demo
155
- route (e.g. `/tabstrip`). The old Chrome DevTools MCP (`.mcp.json` + `chrome-devtools-mcp`) has been
156
- removed in favor of this.
166
+ console inspection, interaction). Point it at the **container IP** `:5173` and the relevant demo route
167
+ (e.g. `/tabstrip`). The old Chrome DevTools MCP (`.mcp.json` + `chrome-devtools-mcp`) has been removed
168
+ in favor of this. **Browser-binary gotcha:** the Playwright in `node_modules` and the cached browser
169
+ revision can mismatch (e.g. it wants `headless_shell-1181` but the cache has `chromium-1229`); pass
170
+ `executablePath` to the installed `~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome` instead of
171
+ re-downloading.
157
172
 
158
173
  ### Demo pages (`src/routes/<component>/`)
159
174
 
package/MIGRATION.md CHANGED
@@ -37,7 +37,7 @@ FieldChrome + bordered field box + chevron). Differences:
37
37
  | --- | --- |
38
38
  | `options`, `value`, `placeholder`, `disabled`, `size`, `variant`, `borderless`, `name`, `class`, `label`, `aside`, `error`, `description`, `required`, `onSelection` | ✅ Honoured (identical semantics). `name` now sits on the `<select>` itself — it submits natively, so no hidden input. |
39
39
  | `inputClass` | ✅ Applied to the `<select>` (alias of the new `selectClass`). |
40
- | `multiSelect` | ⚠️ Maps to native `<select multiple>` — a **listbox**, not the dropdown trigger. Single-select is the pixel-perfect path; the multi appearance intentionally diverges. |
40
+ | `multiSelect` | ⚠️ Maps to native `<select multiple>` — a **listbox**, not the dropdown trigger. Renders as a usable multi-row listbox (bounded height, no chevron); single-select is the pixel-perfect path, the multi appearance intentionally diverges. **Submit shape differs:** `Select` multi submits ONE comma-joined field (`tags=red,blue`); `Select2` multi submits the native repeated-field encoding, so a server collects it as an array — in PHP name the field `tags[]` (a bare `tags` keeps only the last). A PHP action therefore changes when you swap a multi `Select` → `Select2`. |
41
41
  | `itemSnippet`, `itemClass`, `dropdownClass` | ❌ **Accepted but ignored** — the open list is the OS dropdown and can't be styled/custom-rendered. |
42
42
  | `maxVisibleItems`, `itemHeight`, `autoAdvance` | ❌ Accepted but ignored — the OS controls the dropdown. |
43
43
  | `url`, `searchParam`, `transform`, `debounceMs`, `maxResults`, `headers`, `searchTerm`, `onError`, `onLoadStart`, `onLoadEnd` | ❌ Accepted but ignored — **no remote search** (a native `<select>` has no type-to-search box). Load options yourself and pass `options`. |
@@ -98,7 +98,7 @@ drop-in for single/datetime.
98
98
 
99
99
  | `DatePicker` prop | `DatePicker2` behaviour |
100
100
  | --- | --- |
101
- | `value` (single `YYYY-MM-DD`, datetime `YYYY-MM-DDTHH:MM`), `time`, `min`, `max`, `size`, `variant`, `disabled`, `name`, `class` | ✅ Honoured. `name` sits on the `<input>` (submits natively — no hidden input). |
101
+ | `value` (single `YYYY-MM-DD`, datetime `YYYY-MM-DDTHH:MM`), `time`, `min`, `max`, `size`, `variant`, `disabled`, `name`, `class` | ✅ Honoured. `name` sits on the `<input>` (submits natively — no hidden input). Survives `form.reset()` / SvelteKit `use:enhance` (`reset: true`): the input's `defaultValue` tracks the bound value, so a reset restores the date instead of blanking it. |
102
102
  | `range` | ❌ **No native equivalent.** Falls back to a single date input (dev warning). Use two `DatePicker2`s (start/end) or stay on `DatePicker`. |
103
103
  | `step`, `seconds`, `meridiem`, `weekStart` | ❌ Accepted but ignored — the OS picker owns time granularity + week layout. |
104
104
  | `locale` | ❌ Ignored — the native input formats per the user's browser/OS locale. |
@@ -10,7 +10,7 @@
10
10
  */
11
11
  -->
12
12
  <script lang="ts">
13
- import { uiI18n } from "../i18n.js";
13
+ import { uiI18n, safeLocale } from "../i18n.js";
14
14
  const i18n = uiI18n();
15
15
  import type { Size } from "../types/sizes.js";
16
16
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
@@ -45,7 +45,7 @@
45
45
  min,
46
46
  max,
47
47
  weekStart,
48
- locale = i18n.locale ?? (typeof navigator !== "undefined" ? navigator.language : "en-US"),
48
+ locale = safeLocale(i18n.locale),
49
49
  size = "md",
50
50
  variant = "primary",
51
51
  class: cls = "",
@@ -13,7 +13,7 @@
13
13
  */
14
14
  -->
15
15
  <script lang="ts">
16
- import { uiI18n } from "../i18n.js";
16
+ import { uiI18n, safeLocale } from "../i18n.js";
17
17
  const i18n = uiI18n();
18
18
  import type { HTMLAttributes } from "svelte/elements";
19
19
  import type { Size } from "../types/sizes.js";
@@ -60,7 +60,7 @@
60
60
  min,
61
61
  max,
62
62
  weekStart,
63
- locale = i18n.locale ?? (typeof navigator !== "undefined" ? navigator.language : "en-US"),
63
+ locale = safeLocale(i18n.locale),
64
64
  placeholder = range ? "Select dates" : time ? "Select date & time" : "Select date",
65
65
  disabled = false,
66
66
  size = "md",
@@ -88,9 +88,14 @@
88
88
  }
89
89
  </script>
90
90
 
91
+ <!-- `defaultValue` tracks the bound value so `form.reset()` / SvelteKit `use:enhance` (reset:true)
92
+ restores it instead of blanking the native <input>: Svelte binds `value` as a property only, so
93
+ without a matching reset-default a reset wipes the date (and the next native submit posts ""). See
94
+ MIGRATION.md / CHANGELOG. -->
91
95
  <Input
92
96
  type={time ? "datetime-local" : "date"}
93
97
  value={strValue}
98
+ defaultValue={strValue}
94
99
  oninput={(e) => onInput((e.currentTarget as HTMLInputElement).value)}
95
100
  {min}
96
101
  {max}
@@ -183,15 +183,20 @@
183
183
  const padRightClass = $derived(responsiveClasses(size, padRightMap));
184
184
 
185
185
  // Field box — mirrors Input.wrapperClasses (icon on the right → pl-2 pr-0 on the frame).
186
+ // `multiSelect` is a native <select multiple> LISTBOX, not a dropdown trigger: it can't live in the
187
+ // fixed single-line height without clipping, so the box grows to the listbox (items-stretch, auto
188
+ // height, symmetric padding) and the chevron is dropped. Single-select stays pixel-identical.
186
189
  const wrapperClasses = $derived.by(() => {
187
- const base = `relative w-full ${heightClass} flex items-center [border-radius:var(--ui-border-radius)]`;
190
+ const base = multiSelect
191
+ ? `relative w-full flex items-stretch [border-radius:var(--ui-border-radius)]`
192
+ : `relative w-full ${heightClass} flex items-center [border-radius:var(--ui-border-radius)]`;
188
193
  const frameStyles = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
189
194
  const fieldBg =
190
195
  variant === "ghost" ? "bg-transparent" : "[background-color:var(--ui-color-background)]";
191
196
  const themeStyles = [
192
197
  fieldBg,
193
198
  "border",
194
- "pl-2 pr-0",
199
+ multiSelect ? "p-1" : "pl-2 pr-0",
195
200
  frameStyles,
196
201
  "focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)]",
197
202
  "focus-within:[outline-offset:0px]",
@@ -206,8 +211,12 @@
206
211
  });
207
212
 
208
213
  // The <select> itself — mirrors Input's inner <input> (flex-1, transparent, no border/outline).
214
+ // Multi mode keeps the native listbox chrome: full width, native row layout (no appearance-none,
215
+ // no single-line height, no chevron right-padding).
209
216
  const selectClasses = $derived.by(() => {
210
- const base = `flex-1 h-full bg-transparent border-none outline-none ${textClass} ${padRightClass} leading-tight p-0 min-w-0 appearance-none cursor-pointer`;
217
+ const base = multiSelect
218
+ ? `w-full bg-transparent border-none outline-none ${textClass} min-w-0 cursor-pointer`
219
+ : `flex-1 h-full bg-transparent border-none outline-none ${textClass} ${padRightClass} leading-tight p-0 min-w-0 appearance-none cursor-pointer`;
211
220
  const color = hasValue ? "[color:var(--ui-color-text)]" : placeholderVariantColor[variant];
212
221
  const disabledColor =
213
222
  "disabled:[color:color-mix(in_srgb,var(--ui-color-text)_40%,transparent)] disabled:cursor-not-allowed disabled:bg-transparent";
@@ -234,6 +243,7 @@
234
243
  before hydration / with JS off — and `onchange` keeps client state in sync. -->
235
244
  <select
236
245
  multiple={multiSelect}
246
+ size={multiSelect ? Math.max(2, Math.min(options.length, 6)) : undefined}
237
247
  {name}
238
248
  {disabled}
239
249
  {required}
@@ -260,16 +270,19 @@
260
270
  </select>
261
271
 
262
272
  <!-- Decorative chevron — same box/colour/size as Input's right icon. pointer-events-none so a
263
- click anywhere (incl. here) falls through to the <select> beneath. -->
264
- <span
265
- class="pointer-events-none absolute inset-y-0 right-0 {iconBoxClass} flex items-center justify-center select-none"
266
- >
273
+ click anywhere (incl. here) falls through to the <select> beneath. Dropdown affordance only:
274
+ a multi listbox has no dropdown, so it's hidden there. -->
275
+ {#if !multiSelect}
267
276
  <span
268
- class="inline-flex {svgClass} items-center justify-center [color:var(--ui-color-secondary)] [&_svg]:w-full [&_svg]:h-full"
277
+ class="pointer-events-none absolute inset-y-0 right-0 {iconBoxClass} flex items-center justify-center select-none"
269
278
  >
270
- <ChevronDown />
279
+ <span
280
+ class="inline-flex {svgClass} items-center justify-center [color:var(--ui-color-secondary)] [&_svg]:w-full [&_svg]:h-full"
281
+ >
282
+ <ChevronDown />
283
+ </span>
271
284
  </span>
272
- </span>
285
+ {/if}
273
286
  </div>
274
287
  {/snippet}
275
288
  </FieldChrome>
@@ -17,6 +17,7 @@
17
17
  import Input from "./Input.svelte";
18
18
  import Popup from "./Popup.svelte";
19
19
  import TimeSpinner from "./TimeSpinner.svelte";
20
+ import { safeLocale } from "../i18n.js";
20
21
  import IconClock from "~icons/mdi/clock-outline";
21
22
 
22
23
  interface Props extends Omit<HTMLInputAttributes, "value" | "size" | "min" | "max" | "step"> {
@@ -47,7 +48,7 @@
47
48
  min = "00:00",
48
49
  max = "23:59",
49
50
  meridiem = true,
50
- locale = typeof navigator !== "undefined" ? navigator.language : "en-US",
51
+ locale = safeLocale(),
51
52
  size = "md",
52
53
  variant = "primary",
53
54
  disabled = false,
@@ -9,7 +9,7 @@
9
9
  */
10
10
  -->
11
11
  <script lang="ts">
12
- import { uiI18n } from "../i18n.js";
12
+ import { uiI18n, safeLocale } from "../i18n.js";
13
13
  const i18n = uiI18n();
14
14
  import type { HTMLInputAttributes } from "svelte/elements";
15
15
  import type { FieldChromeProps } from "./field-chrome.js";
@@ -51,7 +51,7 @@
51
51
  min = "00:00",
52
52
  max = "23:59",
53
53
  meridiem = true,
54
- locale = typeof navigator !== "undefined" ? navigator.language : "en-US",
54
+ locale = safeLocale(),
55
55
  size = "md",
56
56
  variant = "primary",
57
57
  disabled = false,
@@ -14,6 +14,7 @@
14
14
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
15
15
  import { variantToken, type Variant } from "../types/variants.js";
16
16
  import { resolveStep, type StepSpec } from "../types/time.js";
17
+ import { safeLocale } from "../i18n.js";
17
18
  import IconUp from "~icons/mdi/chevron-up";
18
19
  import IconDown from "~icons/mdi/chevron-down";
19
20
 
@@ -44,7 +45,7 @@
44
45
  min,
45
46
  max,
46
47
  meridiem = true,
47
- locale = typeof navigator !== "undefined" ? navigator.language : "en-US",
48
+ locale = safeLocale(),
48
49
  size = "md",
49
50
  variant = "primary",
50
51
  class: cls = "",
package/dist/i18n.d.ts CHANGED
@@ -71,6 +71,16 @@ export type UiMessages = {
71
71
  };
72
72
  /** English defaults — the exact literals the components used to hard-code (no behaviour change). */
73
73
  export declare const defaultUiMessages: UiMessages;
74
+ /**
75
+ * Resolve a valid BCP-47 locale tag for `Intl.*`. A raw `navigator.language` is NOT guaranteed to be a
76
+ * valid BCP-47 tag — on a POSIX/`LANG=C` host the browser can report `en-US@posix`, and handing that
77
+ * straight to `Intl.Locale`/`Intl.DateTimeFormat` throws `RangeError: Invalid language tag`, which (in a
78
+ * component's reactive scope) kills hydration. This validates the preferred tag, then
79
+ * `navigator.language`, falling back to each candidate's primary subtag (e.g. `en-US@posix` → `en`) and
80
+ * finally `"en-US"`. A tag that is already valid is returned unchanged, so formatted output is
81
+ * byte-identical for every well-formed locale.
82
+ */
83
+ export declare function safeLocale(preferred?: string): string;
74
84
  /** What a component reads. `locale` is `undefined` unless a provider sets one — Intl components then
75
85
  * fall back to `navigator.language`, preserving current behaviour for no-op consumers. */
76
86
  export type UiI18n = {
package/dist/i18n.js CHANGED
@@ -61,6 +61,36 @@ export const defaultUiMessages = {
61
61
  selectTheme: "Select theme...",
62
62
  menu: "Menu",
63
63
  };
64
+ /**
65
+ * Resolve a valid BCP-47 locale tag for `Intl.*`. A raw `navigator.language` is NOT guaranteed to be a
66
+ * valid BCP-47 tag — on a POSIX/`LANG=C` host the browser can report `en-US@posix`, and handing that
67
+ * straight to `Intl.Locale`/`Intl.DateTimeFormat` throws `RangeError: Invalid language tag`, which (in a
68
+ * component's reactive scope) kills hydration. This validates the preferred tag, then
69
+ * `navigator.language`, falling back to each candidate's primary subtag (e.g. `en-US@posix` → `en`) and
70
+ * finally `"en-US"`. A tag that is already valid is returned unchanged, so formatted output is
71
+ * byte-identical for every well-formed locale.
72
+ */
73
+ export function safeLocale(preferred) {
74
+ const isValid = (tag) => {
75
+ try {
76
+ return Intl.getCanonicalLocales(tag).length > 0;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ };
82
+ const candidates = [preferred, typeof navigator !== "undefined" ? navigator.language : undefined];
83
+ for (const c of candidates) {
84
+ if (!c)
85
+ continue;
86
+ if (isValid(c))
87
+ return c;
88
+ const primary = c.split(/[-_@.]/)[0]; // strip region/extension/POSIX modifier/charset
89
+ if (primary && isValid(primary))
90
+ return primary;
91
+ }
92
+ return "en-US";
93
+ }
64
94
  const KEY = Symbol("svelte-ui-i18n");
65
95
  const resolve = (v) => typeof v === "function" ? v() : v;
66
96
  /**
package/dist/index.d.ts CHANGED
@@ -86,7 +86,7 @@ export { anchored, computePlacement, resolveBoundary } from "./positioning/ancho
86
86
  export type { Side, Align, Boundary, PlaceOptions, Placement, AnchoredParams, } from "./positioning/anchored.js";
87
87
  export { tooltip } from "./positioning/tooltip.js";
88
88
  export type { TooltipOptions } from "./positioning/tooltip.js";
89
- export { setUiI18n, uiI18n, defaultUiMessages } from "./i18n.js";
89
+ export { setUiI18n, uiI18n, defaultUiMessages, safeLocale } from "./i18n.js";
90
90
  export type { UiMessages, UiI18n } from "./i18n.js";
91
91
  export type { Variant } from "./types/variants.js";
92
92
  export type { Size } from "./types/sizes.js";
package/dist/index.js CHANGED
@@ -85,6 +85,6 @@ export { anchored, computePlacement, resolveBoundary } from "./positioning/ancho
85
85
  // Tooltip action (use:tooltip) — themed tooltips on the `anchored` engine, no global handler.
86
86
  export { tooltip } from "./positioning/tooltip.js";
87
87
  // Internal localization (opt-in) — override svelte-ui's own microcopy + Intl locale at the app root.
88
- export { setUiI18n, uiI18n, defaultUiMessages } from "./i18n.js";
88
+ export { setUiI18n, uiI18n, defaultUiMessages, safeLocale } from "./i18n.js";
89
89
  export { BREAKPOINT_PX } from "./types/breakpoints.js";
90
90
  export { responsiveClasses, resolveScalar } from "./types/responsive.js";
@@ -106,7 +106,10 @@ shows the right option in SSR / before hydration. The **closed control is pixel-
106
106
  - **Intentional divergences** (native `<select>` limits): the open list is the OS dropdown (no
107
107
  `itemSnippet`/`itemClass`/`dropdownClass`), **no remote search** (`url`/`searchParam`/`transform`/…
108
108
  are accepted but ignored — load `options` yourself), no `methods.openDropdown()`, and `multiSelect`
109
- maps to a native `<select multiple>` listbox. Full matrix + codemod: `MIGRATION.md`.
109
+ maps to a native `<select multiple>` listbox (a usable multi-row box, not the dropdown trigger). Note
110
+ the multi **submit shape** differs from `Select`: `Select` multi posts one comma-joined field, while
111
+ `Select2` multi posts the native repeated-field encoding — collect it as an array (in PHP, name the
112
+ field `tags[]`). Full matrix + codemod: `MIGRATION.md`.
110
113
  - **Use when**: a static choice must render/submit server-side without JS. Stay on `Select` for
111
114
  remote search, custom option rendering, or the multi-select trigger look.
112
115
  - **Transitional**: ships alongside `Select` under the `2` suffix; see `MIGRATION.md` for the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.21.1-dev.124.7b5c577",
3
+ "version": "0.21.1-dev.126.3149ff9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"