@spaethtech/svelte-ui 0.21.1-dev.117.092ebb0 → 0.21.1-dev.120.d0d8cfa
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 +60 -0
- package/dist/components/Select2.svelte +288 -0
- package/dist/components/Select2.svelte.d.ts +71 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/docs/components.md +21 -0
- package/package.json +6 -1
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Migration guide
|
|
2
|
+
|
|
3
|
+
This file tracks **breaking or behaviour-changing** moves between versions — anything a consumer must
|
|
4
|
+
act on. Non-breaking additions live in `CHANGELOG.md`; only things that can bite a downstream app on
|
|
5
|
+
upgrade are documented here, each with a dated heading and a concrete before/after.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The `*2` native-first variants (transitional)
|
|
10
|
+
|
|
11
|
+
Several components are getting a **native-first, no-JS / SSR-correct** rebuild so SSR apps (e.g. a
|
|
12
|
+
PHP-rendered front end) render and submit them with **no client JavaScript**. Rather than change the
|
|
13
|
+
existing components in place and risk the apps that already depend on them, each rebuild ships
|
|
14
|
+
**alongside** the original under a `2` suffix (`Select` → `Select2`, …):
|
|
15
|
+
|
|
16
|
+
- **Nothing breaks on arrival.** The `2` components are purely additive new exports. Existing
|
|
17
|
+
components are untouched; your current code keeps working.
|
|
18
|
+
- **Try them on `@dev`.** Import the `2` variant (`import { Select2 } from '@spaethtech/svelte-ui'`
|
|
19
|
+
or the `@spaethtech/svelte-ui/select2` subpath) and A/B it against the original in the same app.
|
|
20
|
+
- **The eventual swap is deliberate and versioned.** Once a `2` variant proves out, its
|
|
21
|
+
implementation replaces the original's (`Select2` → `Select`), the `2` export is removed, and that
|
|
22
|
+
single swap is documented here with its compatibility delta and a MINOR version bump. The only
|
|
23
|
+
thing that can break at that point is a **direct `Select2` import** — which should only ever exist
|
|
24
|
+
in the test bed, never in shipped app code.
|
|
25
|
+
|
|
26
|
+
The `2` variants aim to be a **drop-in prop-superset** of the original: props the native element
|
|
27
|
+
can't honour are still *accepted* (so swapping the component name type-checks) but ignored, and are
|
|
28
|
+
listed per component below. Behavioural differences that a native element imposes are also listed —
|
|
29
|
+
those are the parts you verify before adopting.
|
|
30
|
+
|
|
31
|
+
### `Select2` vs `Select`
|
|
32
|
+
|
|
33
|
+
`Select2` renders a real `<select>`. The **closed control is pixel-identical** to `Select` (same
|
|
34
|
+
FieldChrome + bordered field box + chevron). Differences:
|
|
35
|
+
|
|
36
|
+
| `Select` prop | `Select2` behaviour |
|
|
37
|
+
| --- | --- |
|
|
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
|
+
| `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. |
|
|
41
|
+
| `itemSnippet`, `itemClass`, `dropdownClass` | ❌ **Accepted but ignored** — the open list is the OS dropdown and can't be styled/custom-rendered. |
|
|
42
|
+
| `maxVisibleItems`, `itemHeight`, `autoAdvance` | ❌ Accepted but ignored — the OS controls the dropdown. |
|
|
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`. |
|
|
44
|
+
| `methods.openDropdown()` | ❌ No-op — the OS owns the open/close of a native `<select>`. |
|
|
45
|
+
|
|
46
|
+
**When NOT to swap to `Select2`:** if you rely on remote search, custom option rendering, or the
|
|
47
|
+
multi-select trigger look, stay on `Select`. `Select2` targets the common case: a static list that
|
|
48
|
+
must work server-side / without JS.
|
|
49
|
+
|
|
50
|
+
**Codemod (when you choose to adopt it in an app):**
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# Swap the import + tag. Review each hit — drop any ignored props (url/transform/itemSnippet/…) by hand.
|
|
54
|
+
grep -rl 'Select' src | xargs sed -i \
|
|
55
|
+
-e 's/\bimport { Select }/import { Select2 }/g' \
|
|
56
|
+
-e 's/<Select\b/<Select2/g' -e 's/<\/Select>/<\/Select2>/g'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Verify: the field lines up with its siblings, submits in your form with JS disabled, and shows the
|
|
60
|
+
right option on first paint (view source — the `selected` attribute is server-rendered).
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Select2 — a NATIVE-FIRST, no-JS/SSR-correct sibling of `Select`.
|
|
4
|
+
*
|
|
5
|
+
* This is a transitional `*2` variant (see MIGRATION.md): it renders a real `<select>` element, so
|
|
6
|
+
* it works with JavaScript disabled, submits in a plain `<form>` natively (its `name` carries the
|
|
7
|
+
* value — no hidden input needed), and shows the correct selected option in SSR / before hydration.
|
|
8
|
+
* The chrome (FieldChrome label/aside/error/description + the bordered field box + the right chevron)
|
|
9
|
+
* is drawn with the SAME classes as `Input`, so a closed `Select2` is pixel-identical to `Select`.
|
|
10
|
+
*
|
|
11
|
+
* What's intentionally DIFFERENT from `Select` (inherent to a native `<select>`, see MIGRATION.md):
|
|
12
|
+
* - The open option list is the OS-rendered dropdown — not stylable, no `itemSnippet`/`itemClass`.
|
|
13
|
+
* - No remote search (`url`/`searchParam`/`transform`/…), no `methods.openDropdown()`.
|
|
14
|
+
* - `multiSelect` maps to native `<select multiple>` (a listbox) — the appearance diverges there;
|
|
15
|
+
* single-select is the pixel-perfect path.
|
|
16
|
+
* Those Select props are still ACCEPTED (so this is a drop-in type-superset) but ignored; each is
|
|
17
|
+
* annotated below. When this variant graduates, the shared field box should be extracted from
|
|
18
|
+
* `Input` into a helper both consume (today the classes are copied verbatim to avoid touching Input).
|
|
19
|
+
*/
|
|
20
|
+
import type { Snippet } from "svelte";
|
|
21
|
+
import type { HTMLSelectAttributes } from "svelte/elements";
|
|
22
|
+
import type { FieldChromeProps } from "./field-chrome.js";
|
|
23
|
+
import ChevronDown from "~icons/mdi/chevron-down";
|
|
24
|
+
import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
|
|
25
|
+
import type { Size } from "../types/sizes.js";
|
|
26
|
+
import { responsiveClasses, type Responsive } from "../types/responsive.js";
|
|
27
|
+
import type { Variant } from "../types/variants.js";
|
|
28
|
+
|
|
29
|
+
type Option = Record<string, any> & {
|
|
30
|
+
value: string;
|
|
31
|
+
label: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
options = [],
|
|
36
|
+
value = $bindable(""),
|
|
37
|
+
multiSelect = false,
|
|
38
|
+
placeholder = "Select an option...",
|
|
39
|
+
disabled = false,
|
|
40
|
+
size = "md",
|
|
41
|
+
variant = "neutral",
|
|
42
|
+
borderless = false,
|
|
43
|
+
name,
|
|
44
|
+
class: className = "",
|
|
45
|
+
selectClass = "",
|
|
46
|
+
inputClass = "", // Select alias → applied to the <select> too (drop-in compat).
|
|
47
|
+
onSelection,
|
|
48
|
+
// Field chrome.
|
|
49
|
+
label = null,
|
|
50
|
+
aside,
|
|
51
|
+
error = null,
|
|
52
|
+
description = null,
|
|
53
|
+
id,
|
|
54
|
+
required = false,
|
|
55
|
+
// ── Accepted for drop-in compatibility with Select, but NO native equivalent (ignored). ──
|
|
56
|
+
maxVisibleItems: _maxVisibleItems,
|
|
57
|
+
itemHeight: _itemHeight,
|
|
58
|
+
autoAdvance: _autoAdvance,
|
|
59
|
+
dropdownClass: _dropdownClass,
|
|
60
|
+
itemClass: _itemClass,
|
|
61
|
+
itemSnippet: _itemSnippet,
|
|
62
|
+
methods = $bindable(),
|
|
63
|
+
url: _url,
|
|
64
|
+
searchParam: _searchParam,
|
|
65
|
+
transform: _transform,
|
|
66
|
+
debounceMs: _debounceMs,
|
|
67
|
+
maxResults: _maxResults,
|
|
68
|
+
headers: _headers,
|
|
69
|
+
searchTerm = $bindable(""),
|
|
70
|
+
onError: _onError,
|
|
71
|
+
onLoadStart: _onLoadStart,
|
|
72
|
+
onLoadEnd: _onLoadEnd,
|
|
73
|
+
...restProps
|
|
74
|
+
}: {
|
|
75
|
+
options?: Option[];
|
|
76
|
+
value?: string | string[];
|
|
77
|
+
multiSelect?: boolean;
|
|
78
|
+
placeholder?: string;
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
/** Shared size axis — maps to the field height, text, chevron box (matches Input/Select). */
|
|
81
|
+
size?: Responsive<Size>;
|
|
82
|
+
/** Shared variant axis — tints the field border + placeholder (matches Input/Select). */
|
|
83
|
+
variant?: Variant;
|
|
84
|
+
/** Drop the visible frame while keeping the 1px box + focus outline (matches Input). */
|
|
85
|
+
borderless?: boolean;
|
|
86
|
+
/** Native form field name — the `<select>` carries it, so the value submits with no JS. */
|
|
87
|
+
name?: string;
|
|
88
|
+
class?: string;
|
|
89
|
+
/** Extra classes on the `<select>` element. */
|
|
90
|
+
selectClass?: string;
|
|
91
|
+
/** Select alias for `selectClass` (drop-in). */
|
|
92
|
+
inputClass?: string;
|
|
93
|
+
onSelection?: (option: Option) => void;
|
|
94
|
+
/** Native required flag — forwarded to the `<select>` + wired to the FieldChrome label `*`. */
|
|
95
|
+
required?: boolean;
|
|
96
|
+
// ── Accepted-but-ignored (no native equivalent) — see MIGRATION.md ──
|
|
97
|
+
maxVisibleItems?: number;
|
|
98
|
+
itemHeight?: number;
|
|
99
|
+
autoAdvance?: boolean;
|
|
100
|
+
dropdownClass?: string;
|
|
101
|
+
itemClass?: string;
|
|
102
|
+
itemSnippet?: Snippet<[Option, number, boolean, boolean]>;
|
|
103
|
+
methods?: any;
|
|
104
|
+
url?: string;
|
|
105
|
+
searchParam?: string;
|
|
106
|
+
transform?: (apiResponse: any) => Option[];
|
|
107
|
+
debounceMs?: number;
|
|
108
|
+
maxResults?: number;
|
|
109
|
+
headers?: Record<string, string>;
|
|
110
|
+
searchTerm?: string;
|
|
111
|
+
onError?: (error: Error) => void;
|
|
112
|
+
onLoadStart?: () => void;
|
|
113
|
+
onLoadEnd?: () => void;
|
|
114
|
+
} & Omit<
|
|
115
|
+
HTMLSelectAttributes,
|
|
116
|
+
"value" | "size" | "class" | "multiple" | "name" | "disabled" | "required"
|
|
117
|
+
> &
|
|
118
|
+
FieldChromeProps = $props();
|
|
119
|
+
|
|
120
|
+
// `methods.openDropdown()` can't drive a native <select> (the OS owns it); expose a no-op so a
|
|
121
|
+
// consumer binding `methods` doesn't crash. (Documented divergence — see MIGRATION.md.)
|
|
122
|
+
$effect(() => {
|
|
123
|
+
methods = { openDropdown: () => {} };
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const generatedId = nextFieldId();
|
|
127
|
+
const controlId = $derived(id ?? generatedId);
|
|
128
|
+
|
|
129
|
+
// Is a real value selected (vs. resting on the placeholder)? Drives the muted placeholder colour.
|
|
130
|
+
const hasValue = $derived(
|
|
131
|
+
multiSelect
|
|
132
|
+
? Array.isArray(value) && value.length > 0
|
|
133
|
+
: value !== "" && value !== null && value !== undefined,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
|
|
137
|
+
const invalid = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
|
|
138
|
+
|
|
139
|
+
// ── Size axis — copied VERBATIM from Input so the field box is pixel-identical. ──
|
|
140
|
+
const heightMap: Record<Size, string> = { sm: "h-6", md: "h-8", lg: "h-10" };
|
|
141
|
+
const iconBoxMap: Record<Size, string> = { sm: "w-6 h-6", md: "w-8 h-8", lg: "w-10 h-10" };
|
|
142
|
+
const textMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
|
|
143
|
+
const svgMap: Record<Size, string> = { sm: "w-3.5 h-3.5", md: "w-4 h-4", lg: "w-5 h-5" };
|
|
144
|
+
// Right padding on the <select> = chevron box width, so text never underlaps the chevron.
|
|
145
|
+
const padRightMap: Record<Size, string> = { sm: "pr-6", md: "pr-8", lg: "pr-10" };
|
|
146
|
+
|
|
147
|
+
// ── Variant border colour — copied VERBATIM from Input. ──
|
|
148
|
+
const borderVariantClass: Record<Variant, string> = {
|
|
149
|
+
secondary:
|
|
150
|
+
"[border-color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border),transparent)] hover:[border-color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border-hover),transparent)]",
|
|
151
|
+
primary:
|
|
152
|
+
"[border-color:color-mix(in_srgb,var(--ui-color-primary)_var(--ui-tint-border),transparent)] hover:[border-color:color-mix(in_srgb,var(--ui-color-primary)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--ui-color-primary)_var(--ui-tint-border-hover),transparent)]",
|
|
153
|
+
danger:
|
|
154
|
+
"[border-color:color-mix(in_srgb,var(--ui-color-error)_var(--ui-tint-border),transparent)] hover:[border-color:color-mix(in_srgb,var(--ui-color-error)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--ui-color-error)_var(--ui-tint-border-hover),transparent)]",
|
|
155
|
+
info: "[border-color:color-mix(in_srgb,var(--ui-color-info)_var(--ui-tint-border),transparent)] hover:[border-color:color-mix(in_srgb,var(--ui-color-info)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--ui-color-info)_var(--ui-tint-border-hover),transparent)]",
|
|
156
|
+
success:
|
|
157
|
+
"[border-color:color-mix(in_srgb,var(--ui-color-success)_var(--ui-tint-border),transparent)] hover:[border-color:color-mix(in_srgb,var(--ui-color-success)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--ui-color-success)_var(--ui-tint-border-hover),transparent)]",
|
|
158
|
+
warning:
|
|
159
|
+
"[border-color:color-mix(in_srgb,var(--ui-color-warning)_var(--ui-tint-border),transparent)] hover:[border-color:color-mix(in_srgb,var(--ui-color-warning)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--ui-color-warning)_var(--ui-tint-border-hover),transparent)]",
|
|
160
|
+
neutral:
|
|
161
|
+
"[border-color:color-mix(in_srgb,var(--ui-color-text)_var(--ui-tint-border),transparent)] hover:[border-color:color-mix(in_srgb,var(--ui-color-text)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--ui-color-text)_var(--ui-tint-border-hover),transparent)]",
|
|
162
|
+
ghost:
|
|
163
|
+
"[border-color:color-mix(in_srgb,var(--ui-color-text)_var(--ui-tint-border),transparent)] hover:[border-color:color-mix(in_srgb,var(--ui-color-text)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--ui-color-text)_var(--ui-tint-border-hover),transparent)]",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ── Placeholder colour by variant — copied VERBATIM from Input's placeholderVariantClass values. ──
|
|
167
|
+
const placeholderVariantColor: Record<Variant, string> = {
|
|
168
|
+
secondary:
|
|
169
|
+
"[color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border),transparent)]",
|
|
170
|
+
primary: "[color:color-mix(in_srgb,var(--ui-color-primary)_var(--ui-tint-border),transparent)]",
|
|
171
|
+
danger: "[color:color-mix(in_srgb,var(--ui-color-error)_var(--ui-tint-border),transparent)]",
|
|
172
|
+
info: "[color:color-mix(in_srgb,var(--ui-color-info)_var(--ui-tint-border),transparent)]",
|
|
173
|
+
success: "[color:color-mix(in_srgb,var(--ui-color-success)_var(--ui-tint-border),transparent)]",
|
|
174
|
+
warning: "[color:color-mix(in_srgb,var(--ui-color-warning)_var(--ui-tint-border),transparent)]",
|
|
175
|
+
neutral: "[color:color-mix(in_srgb,var(--ui-color-text)_var(--ui-tint-border),transparent)]",
|
|
176
|
+
ghost: "[color:color-mix(in_srgb,var(--ui-color-text)_var(--ui-tint-border),transparent)]",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const heightClass = $derived(responsiveClasses(size, heightMap));
|
|
180
|
+
const iconBoxClass = $derived(responsiveClasses(size, iconBoxMap));
|
|
181
|
+
const textClass = $derived(responsiveClasses(size, textMap));
|
|
182
|
+
const svgClass = $derived(responsiveClasses(size, svgMap));
|
|
183
|
+
const padRightClass = $derived(responsiveClasses(size, padRightMap));
|
|
184
|
+
|
|
185
|
+
// Field box — mirrors Input.wrapperClasses (icon on the right → pl-2 pr-0 on the frame).
|
|
186
|
+
const wrapperClasses = $derived.by(() => {
|
|
187
|
+
const base = `relative w-full ${heightClass} flex items-center [border-radius:var(--ui-border-radius)]`;
|
|
188
|
+
const frameStyles = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
|
|
189
|
+
const fieldBg =
|
|
190
|
+
variant === "ghost" ? "bg-transparent" : "[background-color:var(--ui-color-background)]";
|
|
191
|
+
const themeStyles = [
|
|
192
|
+
fieldBg,
|
|
193
|
+
"border",
|
|
194
|
+
"pl-2 pr-0",
|
|
195
|
+
frameStyles,
|
|
196
|
+
"focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)]",
|
|
197
|
+
"focus-within:[outline-offset:0px]",
|
|
198
|
+
].join(" ");
|
|
199
|
+
const validationStyles = invalid
|
|
200
|
+
? "[border-color:var(--ui-color-error)] focus-within:[ring-color:var(--ui-color-error)]"
|
|
201
|
+
: "";
|
|
202
|
+
const disabledStyles = disabled
|
|
203
|
+
? "![background-color:color-mix(in_srgb,var(--ui-color-text)_5%,transparent)] [border-color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border),transparent)] cursor-not-allowed"
|
|
204
|
+
: "";
|
|
205
|
+
return `${base} ${themeStyles} ${validationStyles} ${disabledStyles}`.trim();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// The <select> itself — mirrors Input's inner <input> (flex-1, transparent, no border/outline).
|
|
209
|
+
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`;
|
|
211
|
+
const color = hasValue ? "[color:var(--ui-color-text)]" : placeholderVariantColor[variant];
|
|
212
|
+
const disabledColor =
|
|
213
|
+
"disabled:[color:color-mix(in_srgb,var(--ui-color-text)_40%,transparent)] disabled:cursor-not-allowed disabled:bg-transparent";
|
|
214
|
+
return `${base} ${color} ${disabledColor} ${selectClass} ${inputClass} ui-select2`.trim();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
function handleChange(event: Event & { currentTarget: EventTarget & HTMLSelectElement }) {
|
|
218
|
+
const el = event.currentTarget;
|
|
219
|
+
if (multiSelect) {
|
|
220
|
+
value = Array.from(el.selectedOptions).map((o) => o.value);
|
|
221
|
+
} else {
|
|
222
|
+
value = el.value;
|
|
223
|
+
const opt = options.find((o) => o.value === el.value);
|
|
224
|
+
if (opt) onSelection?.(opt);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
</script>
|
|
228
|
+
|
|
229
|
+
<FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} {size} class={className}>
|
|
230
|
+
{#snippet control({ describedBy })}
|
|
231
|
+
<div class={wrapperClasses}>
|
|
232
|
+
<!-- The real control: native <select>. We set `selected` per-option (rather than `bind:value`,
|
|
233
|
+
which forbids a dynamic `multiple`) so SSR emits the correct selected option — right text
|
|
234
|
+
before hydration / with JS off — and `onchange` keeps client state in sync. -->
|
|
235
|
+
<select
|
|
236
|
+
multiple={multiSelect}
|
|
237
|
+
{name}
|
|
238
|
+
{disabled}
|
|
239
|
+
{required}
|
|
240
|
+
id={controlId}
|
|
241
|
+
aria-describedby={describedBy}
|
|
242
|
+
aria-invalid={invalid || undefined}
|
|
243
|
+
aria-required={required || undefined}
|
|
244
|
+
onchange={handleChange}
|
|
245
|
+
{...restProps}
|
|
246
|
+
class={selectClasses}
|
|
247
|
+
>
|
|
248
|
+
<!-- Native placeholder: a disabled, hidden option shown while nothing real is picked. -->
|
|
249
|
+
{#if placeholder && !multiSelect}
|
|
250
|
+
<option value="" disabled hidden selected={!hasValue}>{placeholder}</option>
|
|
251
|
+
{/if}
|
|
252
|
+
{#each options as option (option.value)}
|
|
253
|
+
<option
|
|
254
|
+
value={option.value}
|
|
255
|
+
selected={multiSelect
|
|
256
|
+
? Array.isArray(value) && value.includes(option.value)
|
|
257
|
+
: value === option.value}>{option.label}</option
|
|
258
|
+
>
|
|
259
|
+
{/each}
|
|
260
|
+
</select>
|
|
261
|
+
|
|
262
|
+
<!-- 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
|
+
>
|
|
267
|
+
<span
|
|
268
|
+
class="inline-flex {svgClass} items-center justify-center [color:var(--ui-color-secondary)] [&_svg]:w-full [&_svg]:h-full"
|
|
269
|
+
>
|
|
270
|
+
<ChevronDown />
|
|
271
|
+
</span>
|
|
272
|
+
</span>
|
|
273
|
+
</div>
|
|
274
|
+
{/snippet}
|
|
275
|
+
</FieldChrome>
|
|
276
|
+
|
|
277
|
+
<style>
|
|
278
|
+
/* Kill the UA focus box-shadow on the select (we draw our own focus-within outline on the box). */
|
|
279
|
+
.ui-select2 {
|
|
280
|
+
box-shadow: none !important;
|
|
281
|
+
}
|
|
282
|
+
/* Render the dropdown option list in theme colours where the OS honours it (not guaranteed, but
|
|
283
|
+
harmless where it isn't — the closed control is what we pixel-match). */
|
|
284
|
+
.ui-select2 option {
|
|
285
|
+
color: var(--ui-color-text);
|
|
286
|
+
background-color: var(--ui-color-background);
|
|
287
|
+
}
|
|
288
|
+
</style>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select2 — a NATIVE-FIRST, no-JS/SSR-correct sibling of `Select`.
|
|
3
|
+
*
|
|
4
|
+
* This is a transitional `*2` variant (see MIGRATION.md): it renders a real `<select>` element, so
|
|
5
|
+
* it works with JavaScript disabled, submits in a plain `<form>` natively (its `name` carries the
|
|
6
|
+
* value — no hidden input needed), and shows the correct selected option in SSR / before hydration.
|
|
7
|
+
* The chrome (FieldChrome label/aside/error/description + the bordered field box + the right chevron)
|
|
8
|
+
* is drawn with the SAME classes as `Input`, so a closed `Select2` is pixel-identical to `Select`.
|
|
9
|
+
*
|
|
10
|
+
* What's intentionally DIFFERENT from `Select` (inherent to a native `<select>`, see MIGRATION.md):
|
|
11
|
+
* - The open option list is the OS-rendered dropdown — not stylable, no `itemSnippet`/`itemClass`.
|
|
12
|
+
* - No remote search (`url`/`searchParam`/`transform`/…), no `methods.openDropdown()`.
|
|
13
|
+
* - `multiSelect` maps to native `<select multiple>` (a listbox) — the appearance diverges there;
|
|
14
|
+
* single-select is the pixel-perfect path.
|
|
15
|
+
* Those Select props are still ACCEPTED (so this is a drop-in type-superset) but ignored; each is
|
|
16
|
+
* annotated below. When this variant graduates, the shared field box should be extracted from
|
|
17
|
+
* `Input` into a helper both consume (today the classes are copied verbatim to avoid touching Input).
|
|
18
|
+
*/
|
|
19
|
+
import type { Snippet } from "svelte";
|
|
20
|
+
import type { HTMLSelectAttributes } from "svelte/elements";
|
|
21
|
+
import type { FieldChromeProps } from "./field-chrome.js";
|
|
22
|
+
import type { Size } from "../types/sizes.js";
|
|
23
|
+
import { type Responsive } from "../types/responsive.js";
|
|
24
|
+
import type { Variant } from "../types/variants.js";
|
|
25
|
+
type Option = Record<string, any> & {
|
|
26
|
+
value: string;
|
|
27
|
+
label: string;
|
|
28
|
+
};
|
|
29
|
+
type $$ComponentProps = {
|
|
30
|
+
options?: Option[];
|
|
31
|
+
value?: string | string[];
|
|
32
|
+
multiSelect?: boolean;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
/** Shared size axis — maps to the field height, text, chevron box (matches Input/Select). */
|
|
36
|
+
size?: Responsive<Size>;
|
|
37
|
+
/** Shared variant axis — tints the field border + placeholder (matches Input/Select). */
|
|
38
|
+
variant?: Variant;
|
|
39
|
+
/** Drop the visible frame while keeping the 1px box + focus outline (matches Input). */
|
|
40
|
+
borderless?: boolean;
|
|
41
|
+
/** Native form field name — the `<select>` carries it, so the value submits with no JS. */
|
|
42
|
+
name?: string;
|
|
43
|
+
class?: string;
|
|
44
|
+
/** Extra classes on the `<select>` element. */
|
|
45
|
+
selectClass?: string;
|
|
46
|
+
/** Select alias for `selectClass` (drop-in). */
|
|
47
|
+
inputClass?: string;
|
|
48
|
+
onSelection?: (option: Option) => void;
|
|
49
|
+
/** Native required flag — forwarded to the `<select>` + wired to the FieldChrome label `*`. */
|
|
50
|
+
required?: boolean;
|
|
51
|
+
maxVisibleItems?: number;
|
|
52
|
+
itemHeight?: number;
|
|
53
|
+
autoAdvance?: boolean;
|
|
54
|
+
dropdownClass?: string;
|
|
55
|
+
itemClass?: string;
|
|
56
|
+
itemSnippet?: Snippet<[Option, number, boolean, boolean]>;
|
|
57
|
+
methods?: any;
|
|
58
|
+
url?: string;
|
|
59
|
+
searchParam?: string;
|
|
60
|
+
transform?: (apiResponse: any) => Option[];
|
|
61
|
+
debounceMs?: number;
|
|
62
|
+
maxResults?: number;
|
|
63
|
+
headers?: Record<string, string>;
|
|
64
|
+
searchTerm?: string;
|
|
65
|
+
onError?: (error: Error) => void;
|
|
66
|
+
onLoadStart?: () => void;
|
|
67
|
+
onLoadEnd?: () => void;
|
|
68
|
+
} & Omit<HTMLSelectAttributes, "value" | "size" | "class" | "multiple" | "name" | "disabled" | "required"> & FieldChromeProps;
|
|
69
|
+
declare const Select2: import("svelte").Component<$$ComponentProps, {}, "value" | "searchTerm" | "methods">;
|
|
70
|
+
type Select2 = ReturnType<typeof Select2>;
|
|
71
|
+
export default Select2;
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export { default as Input } from "./components/Input.svelte";
|
|
|
23
23
|
export { default as NotesEditor } from "./components/NotesEditor.svelte";
|
|
24
24
|
export { default as Rating } from "./components/Rating.svelte";
|
|
25
25
|
export { default as Select } from "./components/Select.svelte";
|
|
26
|
+
export { default as Select2 } from "./components/Select2.svelte";
|
|
26
27
|
export { Combobox } from "./components/Combobox/index.js";
|
|
27
28
|
export { TokenInput } from "./components/TokenInput/index.js";
|
|
28
29
|
export { Slider } from "./components/Slider/index.js";
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,8 @@ export { default as Input } from "./components/Input.svelte";
|
|
|
23
23
|
export { default as NotesEditor } from "./components/NotesEditor.svelte";
|
|
24
24
|
export { default as Rating } from "./components/Rating.svelte";
|
|
25
25
|
export { default as Select } from "./components/Select.svelte";
|
|
26
|
+
// Native-first, no-JS/SSR-correct variant of Select (transitional `*2` — see MIGRATION.md).
|
|
27
|
+
export { default as Select2 } from "./components/Select2.svelte";
|
|
26
28
|
export { Combobox } from "./components/Combobox/index.js";
|
|
27
29
|
export { TokenInput } from "./components/TokenInput/index.js";
|
|
28
30
|
export { Slider } from "./components/Slider/index.js";
|
package/docs/components.md
CHANGED
|
@@ -91,6 +91,27 @@ Custom dropdown built on `Input` + `List`.
|
|
|
91
91
|
`maxResults`, `headers`, `searchTerm`)
|
|
92
92
|
- **Modes**: static `options` or search-as-you-type against `url`
|
|
93
93
|
|
|
94
|
+
### Select2 (native-first)
|
|
95
|
+
|
|
96
|
+
A **no-JS / SSR-correct** sibling of `Select` that renders a real `<select>` — it works with
|
|
97
|
+
JavaScript disabled, submits in a plain `<form>` (its `name` carries the value, no hidden input), and
|
|
98
|
+
shows the right option in SSR / before hydration. The **closed control is pixel-identical** to
|
|
99
|
+
`Select` (same FieldChrome + bordered field box + chevron).
|
|
100
|
+
|
|
101
|
+
- **Location**: `src/lib/components/Select2.svelte`
|
|
102
|
+
- **Axes**: `size`, `variant`, `borderless`
|
|
103
|
+
- **Props**: a drop-in **superset** of `Select` — `options`, `value` (bindable), `placeholder`,
|
|
104
|
+
`disabled`, `size`, `variant`, `borderless`, `name`, `multiSelect`, `onSelection`, `selectClass`,
|
|
105
|
+
plus the FieldChrome vocabulary (`label`/`aside`/`error`/`description`/`required`/`id`).
|
|
106
|
+
- **Intentional divergences** (native `<select>` limits): the open list is the OS dropdown (no
|
|
107
|
+
`itemSnippet`/`itemClass`/`dropdownClass`), **no remote search** (`url`/`searchParam`/`transform`/…
|
|
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`.
|
|
110
|
+
- **Use when**: a static choice must render/submit server-side without JS. Stay on `Select` for
|
|
111
|
+
remote search, custom option rendering, or the multi-select trigger look.
|
|
112
|
+
- **Transitional**: ships alongside `Select` under the `2` suffix; see `MIGRATION.md` for the
|
|
113
|
+
swap-and-drop plan.
|
|
114
|
+
|
|
94
115
|
### Combobox
|
|
95
116
|
|
|
96
117
|
Free-text autocomplete — a text input with a suggestion listbox. Unlike `Select` (a closed choice),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spaethtech/svelte-ui",
|
|
3
|
-
"version": "0.21.1-dev.
|
|
3
|
+
"version": "0.21.1-dev.120.d0d8cfa",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/spaethtech/svelte-ui.git"
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"!dist/**/*.test.*",
|
|
36
36
|
"!dist/**/*.spec.*",
|
|
37
37
|
"CLAUDE.md",
|
|
38
|
+
"MIGRATION.md",
|
|
38
39
|
"docs"
|
|
39
40
|
],
|
|
40
41
|
"sideEffects": [
|
|
@@ -136,6 +137,10 @@
|
|
|
136
137
|
"types": "./dist/components/Select.svelte.d.ts",
|
|
137
138
|
"svelte": "./dist/components/Select.svelte"
|
|
138
139
|
},
|
|
140
|
+
"./select2": {
|
|
141
|
+
"types": "./dist/components/Select2.svelte.d.ts",
|
|
142
|
+
"svelte": "./dist/components/Select2.svelte"
|
|
143
|
+
},
|
|
139
144
|
"./combobox": {
|
|
140
145
|
"types": "./dist/components/Combobox/index.d.ts",
|
|
141
146
|
"svelte": "./dist/components/Combobox/index.js"
|