@spaethtech/svelte-ui 0.21.1-dev.125.4911439 → 0.21.1-dev.127.89441ce

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/MIGRATION.md CHANGED
@@ -8,6 +8,16 @@ upgrade are documented here, each with a dated heading and a concrete before/aft
8
8
 
9
9
  ## The `*2` native-first variants (transitional)
10
10
 
11
+ > ### ⚠️ EXPERIMENTAL — proof-of-concept, NOT ready for use
12
+ >
13
+ > The `*2` components (`Select2`, `Disclosure2`, `Accordion2`, `DatePicker2`) are a **proof-of-concept
14
+ > under active evaluation in a single test bed (php-svelte) only**. They ship on the **`@dev`** channel
15
+ > so that test bed can exercise them — **do not adopt them in any other project yet.** The API, the
16
+ > behaviour, and even whether each one survives are still in flux (e.g. reset semantics are still being
17
+ > settled). Treat them as unstable: no compatibility promise, and they may change or be removed without
18
+ > a migration note while in this phase. When one is actually ready, this banner comes off and the swap
19
+ > is announced here. Until then: **look, test in php-svelte, don't build on them.**
20
+
11
21
  Several components are getting a **native-first, no-JS / SSR-correct** rebuild so SSR apps (e.g. a
12
22
  PHP-rendered front end) render and submit them with **no client JavaScript**. Rather than change the
13
23
  existing components in place and risk the apps that already depend on them, each rebuild ships
@@ -37,7 +47,7 @@ FieldChrome + bordered field box + chevron). Differences:
37
47
  | --- | --- |
38
48
  | `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
49
  | `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. |
50
+ | `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
51
  | `itemSnippet`, `itemClass`, `dropdownClass` | ❌ **Accepted but ignored** — the open list is the OS dropdown and can't be styled/custom-rendered. |
42
52
  | `maxVisibleItems`, `itemHeight`, `autoAdvance` | ❌ Accepted but ignored — the OS controls the dropdown. |
43
53
  | `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 +108,7 @@ drop-in for single/datetime.
98
108
 
99
109
  | `DatePicker` prop | `DatePicker2` behaviour |
100
110
  | --- | --- |
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). |
111
+ | `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
112
  | `range` | ❌ **No native equivalent.** Falls back to a single date input (dev warning). Use two `DatePicker2`s (start/end) or stay on `DatePicker`. |
103
113
  | `step`, `seconds`, `meridiem`, `weekStart` | ❌ Accepted but ignored — the OS picker owns time granularity + week layout. |
104
114
  | `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";
@@ -93,6 +93,8 @@ Custom dropdown built on `Input` + `List`.
93
93
 
94
94
  ### Select2 (native-first)
95
95
 
96
+ > ⚠️ **Experimental — proof-of-concept, NOT ready for use.** Evaluated in the php-svelte test bed only, on the `@dev` channel; the API/behaviour are unstable and it may change or be removed without notice. Don't adopt it in a project yet. See the `*2` banner in `MIGRATION.md`.
97
+
96
98
  A **no-JS / SSR-correct** sibling of `Select` that renders a real `<select>` — it works with
97
99
  JavaScript disabled, submits in a plain `<form>` (its `name` carries the value, no hidden input), and
98
100
  shows the right option in SSR / before hydration. The **closed control is pixel-identical** to
@@ -106,7 +108,10 @@ shows the right option in SSR / before hydration. The **closed control is pixel-
106
108
  - **Intentional divergences** (native `<select>` limits): the open list is the OS dropdown (no
107
109
  `itemSnippet`/`itemClass`/`dropdownClass`), **no remote search** (`url`/`searchParam`/`transform`/…
108
110
  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`.
111
+ maps to a native `<select multiple>` listbox (a usable multi-row box, not the dropdown trigger). Note
112
+ the multi **submit shape** differs from `Select`: `Select` multi posts one comma-joined field, while
113
+ `Select2` multi posts the native repeated-field encoding — collect it as an array (in PHP, name the
114
+ field `tags[]`). Full matrix + codemod: `MIGRATION.md`.
110
115
  - **Use when**: a static choice must render/submit server-side without JS. Stay on `Select` for
111
116
  remote search, custom option rendering, or the multi-select trigger look.
112
117
  - **Transitional**: ships alongside `Select` under the `2` suffix; see `MIGRATION.md` for the
@@ -310,6 +315,8 @@ independent. Propagates `size`/`variant`/`disabled` to children.
310
315
 
311
316
  ### Disclosure2 / Accordion2 (native-first)
312
317
 
318
+ > ⚠️ **Experimental — proof-of-concept, NOT ready for use.** Evaluated in the php-svelte test bed only, on the `@dev` channel; the API/behaviour are unstable and it may change or be removed without notice. Don't adopt it in a project yet. See the `*2` banner in `MIGRATION.md`.
319
+
313
320
  **No-JS / SSR-correct** siblings built on real `<details>/<summary>`: they expand, rotate the chevron,
314
321
  and (via `Accordion2`'s shared `<details name>`) enforce single-open **with no JavaScript**, correct
315
322
  in SSR. The `<summary>` wears `Button`'s ghost-header classes, so the header is **pixel-identical** to
@@ -417,6 +424,8 @@ snaps to canonical on blur.
417
424
 
418
425
  ### DatePicker2 (native-first)
419
426
 
427
+ > ⚠️ **Experimental — proof-of-concept, NOT ready for use.** Evaluated in the php-svelte test bed only, on the `@dev` channel; the API/behaviour are unstable and it may change or be removed without notice. Don't adopt it in a project yet. See the `*2` banner in `MIGRATION.md`.
428
+
420
429
  **No-JS / SSR-correct** date entry: reuses `Input` with a real `<input type="date">` (or
421
430
  `datetime-local` in `time` mode), so it works without JavaScript, pops the browser's native picker,
422
431
  and submits via `name`. The field **frame** is pixel-identical to `DatePicker` (same `Input`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.21.1-dev.125.4911439",
3
+ "version": "0.21.1-dev.127.89441ce",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"