@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 +19 -4
- package/MIGRATION.md +2 -2
- package/dist/components/Calendar.svelte +2 -2
- package/dist/components/DatePicker.svelte +2 -2
- package/dist/components/DatePicker2.svelte +5 -0
- package/dist/components/Select2.svelte +23 -10
- package/dist/components/TimePicker.svelte +2 -1
- package/dist/components/TimeRangeInput.svelte +2 -2
- package/dist/components/TimeSpinner.svelte +2 -1
- package/dist/i18n.d.ts +10 -0
- package/dist/i18n.js +30 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/docs/components.md +4 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
265
|
-
|
|
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="
|
|
277
|
+
class="pointer-events-none absolute inset-y-0 right-0 {iconBoxClass} flex items-center justify-center select-none"
|
|
269
278
|
>
|
|
270
|
-
<
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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";
|
package/docs/components.md
CHANGED
|
@@ -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
|
|
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
|