@spaethtech/svelte-ui 0.21.1-dev.118.553a240 → 0.21.1-dev.121.3e3f7cf
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 +89 -0
- package/dist/components/Accordion2.svelte +76 -0
- package/dist/components/Accordion2.svelte.d.ts +20 -0
- package/dist/components/Disclosure2.svelte +198 -0
- package/dist/components/Disclosure2.svelte.d.ts +27 -0
- package/dist/components/Select2.svelte +288 -0
- package/dist/components/Select2.svelte.d.ts +71 -0
- package/dist/components/disclosure2.d.ts +16 -0
- package/dist/components/disclosure2.js +15 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/docs/components.md +39 -0
- package/package.json +14 -1
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
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).
|
|
61
|
+
|
|
62
|
+
### `Disclosure2` / `Accordion2` vs `Disclosure` / `Accordion`
|
|
63
|
+
|
|
64
|
+
`Disclosure2` is a real `<details>/<summary>`; `Accordion2` groups them with a shared native
|
|
65
|
+
`<details name>`. The **header is pixel-identical** to `Disclosure`'s `Button` header. Differences:
|
|
66
|
+
|
|
67
|
+
| `Disclosure`/`Accordion` prop | `*2` behaviour |
|
|
68
|
+
| --- | --- |
|
|
69
|
+
| `title`, `header`, `icon`, `size`, `variant`, `disabled`, `id`, `class`, `children` | ✅ Honoured (identical look). `disabled` makes the summary non-interactive + `aria-disabled` (a native `<details>` has no real disabled state). |
|
|
70
|
+
| `open` (Disclosure) | ✅ Sets the initial `open` attribute (SSR-correct) and is kept in sync on `toggle`. With JS off, the browser owns subsequent toggles. |
|
|
71
|
+
| `value` (Disclosure, Accordion) | ✅ Used to compute which panel starts open inside `Accordion2`. In `Accordion2` the group `value` is **initial state only** — with JS off the binding doesn't track user toggles (the browser does, via `name`). |
|
|
72
|
+
| `multiple` (Accordion) | ✅ `false` → shared `<details name>` (browser single-open); `true` → no name (independent). |
|
|
73
|
+
| panel height **animation** (`grid-template-rows: 0fr→1fr`) | ⚠️ Open/close is **instant** — a native `<details>` doesn't height-animate (pre-`::details-content`). The chevron still rotates. Matches reduced-motion behaviour. |
|
|
74
|
+
| `collapsible={false}` (Accordion single mode) | ❌ Best-effort — "never zero open" can't be enforced without JS. |
|
|
75
|
+
| panel stays **mounted when closed** (`inert`) | ⚠️ A closed `<details>` hides its content natively; it is not kept in the a11y tree the same way. Don't rely on closed-panel DOM state. |
|
|
76
|
+
|
|
77
|
+
`Disclosure2` adds one prop over `Disclosure`: **`name`** (the native `<details name>` — set it
|
|
78
|
+
yourself for a no-JS group without `Accordion2`, or let `Accordion2` provide it).
|
|
79
|
+
|
|
80
|
+
**When NOT to swap:** if you depend on the height animation or on reading open state back through the
|
|
81
|
+
binding without JS, stay on `Disclosure`/`Accordion`.
|
|
82
|
+
|
|
83
|
+
**Codemod (per app, when adopting):**
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
grep -rl 'Disclosure\|Accordion' src | xargs sed -i \
|
|
87
|
+
-e 's/\b\(Disclosure\|Accordion\)\b/\12/g'
|
|
88
|
+
# Review: this also rewrites import specifiers and any prose; check each hit.
|
|
89
|
+
```
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
/**
|
|
3
|
+
* Accordion2 — a native-first, no-JS sibling of `Accordion` (transitional `*2`, see MIGRATION.md).
|
|
4
|
+
* It groups `Disclosure2` (`<details>`) children. In single-open mode (`multiple={false}`, default)
|
|
5
|
+
* it hands every child the SAME `<details name>`, so the BROWSER enforces "opening one closes the
|
|
6
|
+
* rest" — with no JavaScript, no context callbacks, correct in SSR. `multiple` drops the shared name
|
|
7
|
+
* so each panel toggles independently. `size`/`variant`/`disabled` propagate to children; the group's
|
|
8
|
+
* `value` sets which panel(s) start open.
|
|
9
|
+
*
|
|
10
|
+
* Differences from `Accordion` (see MIGRATION.md): `value` is INITIAL state only — with JS off the
|
|
11
|
+
* browser owns subsequent open/close, so the binding doesn't track user toggles. `collapsible={false}`
|
|
12
|
+
* (never allow zero open) can't be enforced without JS and is treated as best-effort.
|
|
13
|
+
*/
|
|
14
|
+
-->
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import type { Snippet } from "svelte";
|
|
17
|
+
import type { Size } from "../types/sizes.js";
|
|
18
|
+
import type { Variant } from "../types/variants.js";
|
|
19
|
+
import type { Responsive } from "../types/responsive.js";
|
|
20
|
+
import { nextFieldId } from "./FieldChrome.svelte";
|
|
21
|
+
import { setDisclosure2Group } from "./disclosure2.js";
|
|
22
|
+
|
|
23
|
+
let {
|
|
24
|
+
multiple = false,
|
|
25
|
+
value = $bindable(),
|
|
26
|
+
collapsible: _collapsible = true,
|
|
27
|
+
size = "md",
|
|
28
|
+
variant,
|
|
29
|
+
disabled = false,
|
|
30
|
+
class: cls = "",
|
|
31
|
+
children,
|
|
32
|
+
}: {
|
|
33
|
+
/** false (default) = single-open (native `<details name>`); true = independent multi-open. */
|
|
34
|
+
multiple?: boolean;
|
|
35
|
+
/** Open key (single) or keys (multiple). INITIAL state only in the native variant. Bindable. */
|
|
36
|
+
value?: string | string[];
|
|
37
|
+
/** Accepted for Accordion compatibility; not enforceable without JS (best-effort). */
|
|
38
|
+
collapsible?: boolean;
|
|
39
|
+
size?: Responsive<Size>;
|
|
40
|
+
variant?: Variant;
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
class?: string;
|
|
43
|
+
children: Snippet;
|
|
44
|
+
} = $props();
|
|
45
|
+
|
|
46
|
+
// One shared <details name> per Accordion2 instance → browser-enforced single-open.
|
|
47
|
+
const groupName = nextFieldId();
|
|
48
|
+
|
|
49
|
+
setDisclosure2Group({
|
|
50
|
+
inGroup: true,
|
|
51
|
+
get name() {
|
|
52
|
+
return multiple ? undefined : groupName;
|
|
53
|
+
},
|
|
54
|
+
get value() {
|
|
55
|
+
return value;
|
|
56
|
+
},
|
|
57
|
+
get multiple() {
|
|
58
|
+
return multiple;
|
|
59
|
+
},
|
|
60
|
+
get size() {
|
|
61
|
+
return size;
|
|
62
|
+
},
|
|
63
|
+
get variant() {
|
|
64
|
+
return variant;
|
|
65
|
+
},
|
|
66
|
+
get disabled() {
|
|
67
|
+
return disabled;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<div
|
|
73
|
+
class="flex flex-col [&>*:not(:last-child)]:border-b [&>*]:[border-color:var(--ui-border-color)] {cls}"
|
|
74
|
+
>
|
|
75
|
+
{@render children()}
|
|
76
|
+
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { Size } from "../types/sizes.js";
|
|
3
|
+
import type { Variant } from "../types/variants.js";
|
|
4
|
+
import type { Responsive } from "../types/responsive.js";
|
|
5
|
+
type $$ComponentProps = {
|
|
6
|
+
/** false (default) = single-open (native `<details name>`); true = independent multi-open. */
|
|
7
|
+
multiple?: boolean;
|
|
8
|
+
/** Open key (single) or keys (multiple). INITIAL state only in the native variant. Bindable. */
|
|
9
|
+
value?: string | string[];
|
|
10
|
+
/** Accepted for Accordion compatibility; not enforceable without JS (best-effort). */
|
|
11
|
+
collapsible?: boolean;
|
|
12
|
+
size?: Responsive<Size>;
|
|
13
|
+
variant?: Variant;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
class?: string;
|
|
16
|
+
children: Snippet;
|
|
17
|
+
};
|
|
18
|
+
declare const Accordion2: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
19
|
+
type Accordion2 = ReturnType<typeof Accordion2>;
|
|
20
|
+
export default Accordion2;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
/**
|
|
3
|
+
* Disclosure2 — a native-first, no-JS/SSR-correct sibling of `Disclosure` (transitional `*2`, see
|
|
4
|
+
* MIGRATION.md). It is a real `<details>/<summary>`: it expands/collapses with NO JavaScript, is
|
|
5
|
+
* open on first paint when `open` is set (SSR-correct), and gets correct button/expanded semantics
|
|
6
|
+
* from the browser for free. The `<summary>` wears `Button`'s exact ghost-header classes (copied
|
|
7
|
+
* verbatim), so the header is pixel-identical to `Disclosure`'s `<Button>` header — including the
|
|
8
|
+
* resting look, hover tint (native `:hover`), focus ring, and the chevron that rotates on open (pure
|
|
9
|
+
* CSS via `details[open]`).
|
|
10
|
+
*
|
|
11
|
+
* Differences from `Disclosure` (inherent to `<details>`, see MIGRATION.md):
|
|
12
|
+
* - The open/close is INSTANT (no `grid-template-rows` height animation) except on browsers with
|
|
13
|
+
* `::details-content` + `interpolate-size` support, where it eases. Reduced-motion → instant,
|
|
14
|
+
* same as `Disclosure`.
|
|
15
|
+
* - Grouping is native: pass the same `name` to several `Disclosure2`s (what `Accordion2` does) for
|
|
16
|
+
* a browser-enforced single-open accordion — no context/JS. `value` is accepted but unused.
|
|
17
|
+
* - `disabled` makes the summary non-interactive (pointer-events-none + aria-disabled); a native
|
|
18
|
+
* `<details>` has no real disabled state.
|
|
19
|
+
*/
|
|
20
|
+
-->
|
|
21
|
+
<script lang="ts">
|
|
22
|
+
import type { Snippet } from "svelte";
|
|
23
|
+
import ChevronDown from "~icons/mdi/chevron-down";
|
|
24
|
+
import type { Size } from "../types/sizes.js";
|
|
25
|
+
import { responsiveClasses, type Responsive } from "../types/responsive.js";
|
|
26
|
+
import type { Variant } from "../types/variants.js";
|
|
27
|
+
import { nextFieldId } from "./FieldChrome.svelte";
|
|
28
|
+
import { getDisclosure2Group } from "./disclosure2.js";
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
title = null,
|
|
32
|
+
header,
|
|
33
|
+
open = $bindable(false),
|
|
34
|
+
value,
|
|
35
|
+
disabled = false,
|
|
36
|
+
size,
|
|
37
|
+
variant,
|
|
38
|
+
name,
|
|
39
|
+
icon,
|
|
40
|
+
id,
|
|
41
|
+
class: cls = "",
|
|
42
|
+
children,
|
|
43
|
+
}: {
|
|
44
|
+
/** Header text. Use the `header` snippet instead for full custom header content. */
|
|
45
|
+
title?: string | null;
|
|
46
|
+
header?: Snippet;
|
|
47
|
+
/** Initial + bindable open state. Sets the `open` attribute (SSR-correct); kept in sync on toggle.
|
|
48
|
+
Inside an `Accordion2`, the group's `value` matching this panel's `value` also opens it. */
|
|
49
|
+
open?: boolean;
|
|
50
|
+
/** This panel's key — matched against an `Accordion2` group's `value` to set the initial open. */
|
|
51
|
+
value?: string;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
size?: Responsive<Size>;
|
|
54
|
+
/** Header hover/accent (default `ghost`; inherits the Accordion2's variant). */
|
|
55
|
+
variant?: Variant;
|
|
56
|
+
/** Native `<details name>` — same `name` across siblings = browser-enforced single-open group. */
|
|
57
|
+
name?: string;
|
|
58
|
+
icon?: Snippet;
|
|
59
|
+
id?: string;
|
|
60
|
+
class?: string;
|
|
61
|
+
children: Snippet;
|
|
62
|
+
} = $props();
|
|
63
|
+
|
|
64
|
+
const grp = getDisclosure2Group();
|
|
65
|
+
// Resolve axes: explicit prop → group → default (mirrors Disclosure's precedence).
|
|
66
|
+
const effSize = $derived(size ?? grp?.size ?? "md");
|
|
67
|
+
const effVariant = $derived(variant ?? grp?.variant ?? "ghost");
|
|
68
|
+
const effDisabled = $derived(disabled || !!grp?.disabled);
|
|
69
|
+
// Single-open group → share the browser-enforced `<details name>`; multiple/standalone → own name.
|
|
70
|
+
const effName = $derived(name ?? (grp && !grp.multiple ? grp.name : undefined));
|
|
71
|
+
// Initial open: explicit `open`, or (in a group) this panel's `value` matching the group value.
|
|
72
|
+
const groupOpen = $derived(
|
|
73
|
+
grp == null || value == null
|
|
74
|
+
? false
|
|
75
|
+
: grp.multiple
|
|
76
|
+
? Array.isArray(grp.value) && grp.value.includes(value)
|
|
77
|
+
: grp.value === value,
|
|
78
|
+
);
|
|
79
|
+
const effOpen = $derived(open || groupOpen);
|
|
80
|
+
|
|
81
|
+
const uid = nextFieldId();
|
|
82
|
+
const headerId = $derived(id ?? uid);
|
|
83
|
+
const panelId = $derived(`${headerId}-panel`);
|
|
84
|
+
|
|
85
|
+
// ── Header chrome — mirrors a <Button variant size> (borderless=true, the Disclosure default). ──
|
|
86
|
+
const baseClasses =
|
|
87
|
+
"flex w-full items-center [border-radius:var(--ui-border-radius)] font-medium transition-colors cursor-pointer border focus-visible:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-text)_70%,transparent)] focus-visible:[outline-offset:0px]";
|
|
88
|
+
const padMap: Record<Size, string> = { sm: "h-6 px-2.5", md: "h-8 px-4", lg: "h-10 px-6" };
|
|
89
|
+
const textMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
|
|
90
|
+
const svgMap: Record<Size, string> = {
|
|
91
|
+
sm: "[&_svg]:w-3.5 [&_svg]:h-3.5",
|
|
92
|
+
md: "[&_svg]:w-4 [&_svg]:h-4",
|
|
93
|
+
lg: "[&_svg]:w-5 [&_svg]:h-5",
|
|
94
|
+
};
|
|
95
|
+
// FILL — copied VERBATIM from Button.fillClasses.
|
|
96
|
+
const fillClasses: Record<Variant, string> = {
|
|
97
|
+
primary:
|
|
98
|
+
"[background-color:var(--ui-color-primary)] text-white hover:[background-color:color-mix(in_srgb,var(--ui-color-primary)_90%,black)] [&_svg]:fill-current",
|
|
99
|
+
secondary:
|
|
100
|
+
"[background-color:var(--ui-color-secondary)] text-white hover:[background-color:color-mix(in_srgb,var(--ui-color-secondary)_90%,black)] [&_svg]:fill-current",
|
|
101
|
+
danger:
|
|
102
|
+
"[background-color:var(--ui-color-error)] text-white hover:[background-color:color-mix(in_srgb,var(--ui-color-error)_90%,black)] [&_svg]:fill-current",
|
|
103
|
+
info: "[background-color:var(--ui-color-info)] text-white hover:[background-color:color-mix(in_srgb,var(--ui-color-info)_90%,black)] [&_svg]:fill-current",
|
|
104
|
+
success:
|
|
105
|
+
"[background-color:var(--ui-color-success)] text-white hover:[background-color:color-mix(in_srgb,var(--ui-color-success)_90%,black)] [&_svg]:fill-current",
|
|
106
|
+
warning:
|
|
107
|
+
"[background-color:var(--ui-color-warning)] text-white hover:[background-color:color-mix(in_srgb,var(--ui-color-warning)_90%,black)] [&_svg]:fill-current",
|
|
108
|
+
ghost:
|
|
109
|
+
"bg-transparent [color:var(--ui-color-text)] hover:[background-color:var(--ui-color-hover)] [&_svg]:fill-current",
|
|
110
|
+
neutral:
|
|
111
|
+
"[background-color:var(--ui-color-text)] [color:var(--ui-color-background)] hover:[background-color:color-mix(in_srgb,var(--ui-color-text)_85%,var(--ui-color-background))] [&_svg]:fill-current",
|
|
112
|
+
};
|
|
113
|
+
// BORDER (borderless=true: tracks the fill) — copied VERBATIM from Button.borderMatchClasses.
|
|
114
|
+
const borderMatchClasses: Record<Variant, string> = {
|
|
115
|
+
primary:
|
|
116
|
+
"[border-color:var(--ui-color-primary)] hover:[border-color:color-mix(in_srgb,var(--ui-color-primary)_90%,black)]",
|
|
117
|
+
secondary:
|
|
118
|
+
"[border-color:var(--ui-color-secondary)] hover:[border-color:color-mix(in_srgb,var(--ui-color-secondary)_90%,black)]",
|
|
119
|
+
danger:
|
|
120
|
+
"[border-color:var(--ui-color-error)] hover:[border-color:color-mix(in_srgb,var(--ui-color-error)_90%,black)]",
|
|
121
|
+
info: "[border-color:var(--ui-color-info)] hover:[border-color:color-mix(in_srgb,var(--ui-color-info)_90%,black)]",
|
|
122
|
+
success:
|
|
123
|
+
"[border-color:var(--ui-color-success)] hover:[border-color:color-mix(in_srgb,var(--ui-color-success)_90%,black)]",
|
|
124
|
+
warning:
|
|
125
|
+
"[border-color:var(--ui-color-warning)] hover:[border-color:color-mix(in_srgb,var(--ui-color-warning)_90%,black)]",
|
|
126
|
+
ghost: "border-transparent hover:[border-color:var(--ui-color-hover)]",
|
|
127
|
+
neutral:
|
|
128
|
+
"[border-color:var(--ui-color-text)] hover:[border-color:color-mix(in_srgb,var(--ui-color-text)_85%,var(--ui-color-background))]",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const labelText = $derived(responsiveClasses(effSize, textMap));
|
|
132
|
+
const svgClass = $derived(responsiveClasses(effSize, svgMap));
|
|
133
|
+
const summaryClasses = $derived(
|
|
134
|
+
[
|
|
135
|
+
baseClasses,
|
|
136
|
+
responsiveClasses(effSize, padMap),
|
|
137
|
+
labelText,
|
|
138
|
+
responsiveClasses(effVariant, fillClasses),
|
|
139
|
+
responsiveClasses(effVariant, borderMatchClasses),
|
|
140
|
+
effDisabled ? "pointer-events-none opacity-50 cursor-not-allowed" : "",
|
|
141
|
+
"ui-disc2-summary",
|
|
142
|
+
]
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.join(" "),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Keep `open` in sync for JS consumers (harmless with JS off — the browser toggles natively).
|
|
148
|
+
function onToggle(event: Event & { currentTarget: HTMLDetailsElement }) {
|
|
149
|
+
open = event.currentTarget.open;
|
|
150
|
+
}
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<details class="ui-disc2 {cls}" name={effName} open={effOpen} ontoggle={onToggle}>
|
|
154
|
+
<summary
|
|
155
|
+
class={summaryClasses}
|
|
156
|
+
id={headerId}
|
|
157
|
+
aria-controls={panelId}
|
|
158
|
+
aria-disabled={effDisabled || undefined}
|
|
159
|
+
>
|
|
160
|
+
{#if icon}<span class="inline-flex shrink-0 {svgClass}">{@render icon()}</span>{/if}
|
|
161
|
+
{#if header}
|
|
162
|
+
<span class="flex-1 text-left">{@render header()}</span>
|
|
163
|
+
{:else}
|
|
164
|
+
<span class="flex-1 text-left">{title}</span>
|
|
165
|
+
{/if}
|
|
166
|
+
<ChevronDown class="ui-disc2-chevron shrink-0 {svgClass}" />
|
|
167
|
+
</summary>
|
|
168
|
+
|
|
169
|
+
<div
|
|
170
|
+
id={panelId}
|
|
171
|
+
role="region"
|
|
172
|
+
aria-labelledby={headerId}
|
|
173
|
+
class="ui-disc2-panel px-1 pt-1 pb-3 {labelText} [color:var(--ui-color-text)]"
|
|
174
|
+
>
|
|
175
|
+
{@render children()}
|
|
176
|
+
</div>
|
|
177
|
+
</details>
|
|
178
|
+
|
|
179
|
+
<style>
|
|
180
|
+
/* Hide the native disclosure triangle — we draw our own chevron. */
|
|
181
|
+
.ui-disc2 > summary {
|
|
182
|
+
list-style: none;
|
|
183
|
+
}
|
|
184
|
+
.ui-disc2 > summary::-webkit-details-marker {
|
|
185
|
+
display: none;
|
|
186
|
+
}
|
|
187
|
+
/* Chevron rotates on open — pure CSS, so it's correct with JS off and in SSR. Open/close itself is
|
|
188
|
+
instant (a native <details> doesn't height-animate pre-`::details-content`); documented in
|
|
189
|
+
MIGRATION.md. Reduced-motion skips the chevron transition, matching Disclosure. */
|
|
190
|
+
.ui-disc2[open] > summary :global(.ui-disc2-chevron) {
|
|
191
|
+
transform: rotate(180deg);
|
|
192
|
+
}
|
|
193
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
194
|
+
.ui-disc2 :global(.ui-disc2-chevron) {
|
|
195
|
+
transition: transform 200ms;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { Size } from "../types/sizes.js";
|
|
3
|
+
import { type Responsive } from "../types/responsive.js";
|
|
4
|
+
import type { Variant } from "../types/variants.js";
|
|
5
|
+
type $$ComponentProps = {
|
|
6
|
+
/** Header text. Use the `header` snippet instead for full custom header content. */
|
|
7
|
+
title?: string | null;
|
|
8
|
+
header?: Snippet;
|
|
9
|
+
/** Initial + bindable open state. Sets the `open` attribute (SSR-correct); kept in sync on toggle.
|
|
10
|
+
Inside an `Accordion2`, the group's `value` matching this panel's `value` also opens it. */
|
|
11
|
+
open?: boolean;
|
|
12
|
+
/** This panel's key — matched against an `Accordion2` group's `value` to set the initial open. */
|
|
13
|
+
value?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
size?: Responsive<Size>;
|
|
16
|
+
/** Header hover/accent (default `ghost`; inherits the Accordion2's variant). */
|
|
17
|
+
variant?: Variant;
|
|
18
|
+
/** Native `<details name>` — same `name` across siblings = browser-enforced single-open group. */
|
|
19
|
+
name?: string;
|
|
20
|
+
icon?: Snippet;
|
|
21
|
+
id?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
children: Snippet;
|
|
24
|
+
};
|
|
25
|
+
declare const Disclosure2: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
26
|
+
type Disclosure2 = ReturnType<typeof Disclosure2>;
|
|
27
|
+
export default Disclosure2;
|
|
@@ -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;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Size } from "../types/sizes.js";
|
|
2
|
+
import type { Variant } from "../types/variants.js";
|
|
3
|
+
import type { Responsive } from "../types/responsive.js";
|
|
4
|
+
export interface Disclosure2GroupContext {
|
|
5
|
+
inGroup: true;
|
|
6
|
+
/** Shared `<details name>` for single-open mode; `undefined` in multiple mode (independent). */
|
|
7
|
+
name?: string;
|
|
8
|
+
/** The group's open key (single) or keys (multiple) — used to compute each child's initial `open`. */
|
|
9
|
+
value?: string | string[];
|
|
10
|
+
multiple: boolean;
|
|
11
|
+
size?: Responsive<Size>;
|
|
12
|
+
variant?: Variant;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function setDisclosure2Group(ctx: Disclosure2GroupContext): void;
|
|
16
|
+
export declare function getDisclosure2Group(): Disclosure2GroupContext | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared context for `Accordion2` → `Disclosure2` — the native-first sibling of `disclosure.ts`.
|
|
3
|
+
* Unlike the JS group (which owns `isOpen`/`toggle`), this one carries only STATIC data the child
|
|
4
|
+
* reads at render: a shared `<details name>` (single-open mode → the browser enforces exclusivity
|
|
5
|
+
* with no JS) and the group's initial `value`/axes. There is no runtime toggle callback — the native
|
|
6
|
+
* `<details>` elements coordinate themselves.
|
|
7
|
+
*/
|
|
8
|
+
import { getContext, setContext } from "svelte";
|
|
9
|
+
const KEY = Symbol("svelte-ui-disclosure2-group");
|
|
10
|
+
export function setDisclosure2Group(ctx) {
|
|
11
|
+
setContext(KEY, ctx);
|
|
12
|
+
}
|
|
13
|
+
export function getDisclosure2Group() {
|
|
14
|
+
return getContext(KEY);
|
|
15
|
+
}
|
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";
|
|
@@ -69,6 +70,8 @@ export { Stepper } from "./components/Stepper/index.js";
|
|
|
69
70
|
export type { StepItem } from "./components/Stepper/index.js";
|
|
70
71
|
export { default as Disclosure } from "./components/Disclosure.svelte";
|
|
71
72
|
export { default as Accordion } from "./components/Accordion.svelte";
|
|
73
|
+
export { default as Disclosure2 } from "./components/Disclosure2.svelte";
|
|
74
|
+
export { default as Accordion2 } from "./components/Accordion2.svelte";
|
|
72
75
|
export { default as SideBarMenu } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
73
76
|
export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, SideBarMenuMode, SideBarMenuActiveRule, } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
74
77
|
export { Breadcrumbs } from "./components/Breadcrumbs/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";
|
|
@@ -66,6 +68,9 @@ export { Carousel } from "./components/Carousel/index.js";
|
|
|
66
68
|
export { Stepper } from "./components/Stepper/index.js";
|
|
67
69
|
export { default as Disclosure } from "./components/Disclosure.svelte";
|
|
68
70
|
export { default as Accordion } from "./components/Accordion.svelte";
|
|
71
|
+
// Native-first, no-JS/SSR-correct variants (transitional `*2` — see MIGRATION.md).
|
|
72
|
+
export { default as Disclosure2 } from "./components/Disclosure2.svelte";
|
|
73
|
+
export { default as Accordion2 } from "./components/Accordion2.svelte";
|
|
69
74
|
// Navigation Components
|
|
70
75
|
export { default as SideBarMenu } from "./components/SideBarMenu/SideBarMenu.svelte";
|
|
71
76
|
export { Breadcrumbs } from "./components/Breadcrumbs/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),
|
|
@@ -287,6 +308,24 @@ independent. Propagates `size`/`variant`/`disabled` to children.
|
|
|
287
308
|
- **Props**: `multiple` (default `false`), `value` (bindable — `string` single / `string[]` multiple),
|
|
288
309
|
`collapsible` (single mode, default `true`), `size`, `variant`, `disabled`, `class`, `children`.
|
|
289
310
|
|
|
311
|
+
### Disclosure2 / Accordion2 (native-first)
|
|
312
|
+
|
|
313
|
+
**No-JS / SSR-correct** siblings built on real `<details>/<summary>`: they expand, rotate the chevron,
|
|
314
|
+
and (via `Accordion2`'s shared `<details name>`) enforce single-open **with no JavaScript**, correct
|
|
315
|
+
in SSR. The `<summary>` wears `Button`'s ghost-header classes, so the header is **pixel-identical** to
|
|
316
|
+
`Disclosure` at every `size`/`variant`.
|
|
317
|
+
|
|
318
|
+
- **Location**: `src/lib/components/Disclosure2.svelte`, `src/lib/components/Accordion2.svelte`
|
|
319
|
+
- **Props**: a drop-in **superset** of `Disclosure`/`Accordion` — same `title`/`header`/`open`/`value`/
|
|
320
|
+
`size`/`variant`/`disabled`/`icon`/`class`/`children` and `multiple`/`value`. `Disclosure2` also
|
|
321
|
+
takes a native `name` (shared → browser single-open group).
|
|
322
|
+
- **Intentional divergences** (native `<details>`): open/close is **instant** (no height animation —
|
|
323
|
+
reduced-motion parity), the group `value` is **initial state only** (with JS off the browser owns
|
|
324
|
+
toggles, so the binding doesn't track), and `collapsible={false}` is best-effort. Full matrix:
|
|
325
|
+
`MIGRATION.md`.
|
|
326
|
+
- **Use when**: expandable content must work server-side / without JS (FAQs, nav sections).
|
|
327
|
+
- **Transitional**: ships alongside the originals under the `2` suffix; see `MIGRATION.md`.
|
|
328
|
+
|
|
290
329
|
### Rating
|
|
291
330
|
|
|
292
331
|
Star rating in two modes: a read-only display, or an interactive picker.
|
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.121.3e3f7cf",
|
|
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"
|
|
@@ -296,6 +301,14 @@
|
|
|
296
301
|
"types": "./dist/components/Accordion.svelte.d.ts",
|
|
297
302
|
"svelte": "./dist/components/Accordion.svelte"
|
|
298
303
|
},
|
|
304
|
+
"./disclosure2": {
|
|
305
|
+
"types": "./dist/components/Disclosure2.svelte.d.ts",
|
|
306
|
+
"svelte": "./dist/components/Disclosure2.svelte"
|
|
307
|
+
},
|
|
308
|
+
"./accordion2": {
|
|
309
|
+
"types": "./dist/components/Accordion2.svelte.d.ts",
|
|
310
|
+
"svelte": "./dist/components/Accordion2.svelte"
|
|
311
|
+
},
|
|
299
312
|
"./side-bar-menu": {
|
|
300
313
|
"types": "./dist/components/SideBarMenu/SideBarMenu.svelte.d.ts",
|
|
301
314
|
"svelte": "./dist/components/SideBarMenu/SideBarMenu.svelte"
|