@spaethtech/svelte-ui 0.4.1-dev.20.b3f6fb8 → 0.4.1-dev.22.6370a63
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/skills/svelte-ui/SKILL.md +41 -0
- package/.claude/skills/themes/SKILL.md +7 -1
- package/dist/components/Checkbox.svelte +89 -48
- package/dist/components/Checkbox.svelte.d.ts +6 -0
- package/dist/components/FieldChrome.svelte +104 -0
- package/dist/components/FieldChrome.svelte.d.ts +35 -0
- package/dist/components/Input.svelte +76 -30
- package/dist/components/Input.svelte.d.ts +11 -1
- package/dist/components/Select.svelte +5 -1
- package/dist/components/TextArea.svelte +47 -19
- package/dist/components/TextArea.svelte.d.ts +6 -0
- package/dist/components/Toggle.svelte +62 -21
- package/dist/components/Toggle.svelte.d.ts +6 -0
- package/docs/components.md +24 -0
- package/docs/usage.md +39 -0
- package/package.json +1 -1
|
@@ -57,6 +57,47 @@ the components use:
|
|
|
57
57
|
|
|
58
58
|
Both accept a responsive map too, e.g. `size={{ base: "sm", md: "lg" }}`.
|
|
59
59
|
|
|
60
|
+
## Field chrome (label / help / error)
|
|
61
|
+
|
|
62
|
+
Every field-shaped control — `Input`, `Select`, `TextArea`, `Checkbox`, `Toggle`, and the specialized
|
|
63
|
+
inputs — renders its own label + help/error frame from a shared set of props. **Don't hand-wrap fields
|
|
64
|
+
in `<label>` + hint spans** — use these:
|
|
65
|
+
|
|
66
|
+
- **`label`** (string) — rendered **above** the control, left-aligned. Uniform for *every* control
|
|
67
|
+
(checkbox/toggle labels sit above too), so a row of mixed controls at the same `size` lines up
|
|
68
|
+
pixel-perfect. For any other placement, omit `label` and bring your own `<label>`.
|
|
69
|
+
- **`aside`** (Snippet) — right-aligned in the label row: a char count, status badge, link, button.
|
|
70
|
+
- **`error`** (string | Snippet) — inline error row; also flips the error border + `aria-invalid`.
|
|
71
|
+
String is rendered as `{@html}`.
|
|
72
|
+
- **`description`** (string | Snippet) — muted help row below the error (they coexist). String is
|
|
73
|
+
`{@html}` — **author-controlled markup only**, never untrusted input (use the Snippet form for that).
|
|
74
|
+
- **`required`** — appends `*` to the label + sets `aria-required`.
|
|
75
|
+
- **`id`** — auto-generated if omitted; wires `<label for>` + `aria-describedby`.
|
|
76
|
+
|
|
77
|
+
`label`/aria wiring is automatic. Omit all of them and the field renders bare, exactly as a plain
|
|
78
|
+
control.
|
|
79
|
+
|
|
80
|
+
```svelte
|
|
81
|
+
<Input
|
|
82
|
+
label="Slug"
|
|
83
|
+
description="Lowercase, hyphens only."
|
|
84
|
+
error={slugTaken ? "That slug is already taken." : null}
|
|
85
|
+
bind:value={slug}
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<!-- Mixed controls in a grid all align on the same label row + control baseline: -->
|
|
89
|
+
<div class="grid grid-cols-4 gap-4 items-start">
|
|
90
|
+
<Input label="Name" bind:value={name} />
|
|
91
|
+
<Select label="Role" {options} bind:value={role} />
|
|
92
|
+
<Toggle label="Notifications" bind:checked={notify} />
|
|
93
|
+
<Checkbox label="Agree" bind:checked={agree} required />
|
|
94
|
+
</div>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Validation vs. error:** the built-in `validate={(v) => true | "message"}` (on Input/TextArea)
|
|
98
|
+
surfaces through the **native validity tooltip** (shown on form submit); the controlled **`error`**
|
|
99
|
+
prop is the inline row. Both flip the field's error border.
|
|
100
|
+
|
|
60
101
|
## Icons — no `<Icon>` component
|
|
61
102
|
|
|
62
103
|
Import an MDI icon component from `~icons/mdi/*` and render it into a component's **`icon` snippet**;
|
|
@@ -33,11 +33,17 @@ These are the only palette colours a component references. The `variant` axis
|
|
|
33
33
|
non-accent variants `neutral` and `ghost` → `--ui-color-text`). Canonical order:
|
|
34
34
|
`neutral · primary · secondary · success · info · warning · danger · ghost`.
|
|
35
35
|
|
|
36
|
-
- `ghost` — borderless, transparent, hover-tint only (the minimal control).
|
|
36
|
+
- `ghost` — borderless, transparent, hover-tint only (the minimal control). For the box-shaped
|
|
37
|
+
**fields** (Input/Select/TextArea) this also drives the **field background**: `ghost` → transparent,
|
|
38
|
+
every other variant → `--ui-color-background` (so a field reads as a field on any surface).
|
|
37
39
|
- `neutral` — the _uncoloured_ variant (mixes from `--ui-color-text`): **solid** in solid contexts
|
|
38
40
|
(Button/Toggle/Banner → black-on-white / white-on-black), a **tint** in soft contexts (Badge/Alert).
|
|
39
41
|
- (`plain` was removed — it's now `ghost`. Old bordered-`ghost` → `neutral`.)
|
|
40
42
|
|
|
43
|
+
Field **help/error text** uses no new token: the muted `description` row is an inline
|
|
44
|
+
`color-mix(in srgb, var(--ui-color-text) ~65%, transparent)`, and the `error` row uses
|
|
45
|
+
`--ui-color-error`.
|
|
46
|
+
|
|
41
47
|
### Interactive-surface tints — `--ui-accent` + hover/surface/active
|
|
42
48
|
|
|
43
49
|
One tint language for every hover / section / pressed surface, three steps of the SAME mix:
|
|
@@ -12,13 +12,19 @@
|
|
|
12
12
|
* lg 40px — same as Input), with the box centred inside. Drop it next to a `size`-matched Input/Select
|
|
13
13
|
* and everything lines up on the same baseline; the full-height wrapper is the click target while the
|
|
14
14
|
* box itself stays box-sized.
|
|
15
|
+
*
|
|
16
|
+
* FIELD CHROME: `label`/`aside`/`error`/`description` (see FieldChrome.spec.md). When any is set the
|
|
17
|
+
* control is wrapped in the shared FieldChrome (label ABOVE, error/description below); with none set it
|
|
18
|
+
* renders bare + inline exactly as before (zero-regression).
|
|
15
19
|
*/
|
|
16
20
|
-->
|
|
17
21
|
<script lang="ts">
|
|
18
22
|
import type { HTMLInputAttributes } from "svelte/elements";
|
|
23
|
+
import type { Snippet } from "svelte";
|
|
19
24
|
import type { Size } from "../types/sizes.js";
|
|
20
25
|
import { responsiveClasses, type Responsive } from "../types/responsive.js";
|
|
21
26
|
import { variantToken, type Variant } from "../types/variants.js";
|
|
27
|
+
import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
|
|
22
28
|
|
|
23
29
|
let {
|
|
24
30
|
checked = $bindable(false),
|
|
@@ -27,6 +33,12 @@
|
|
|
27
33
|
size = "md",
|
|
28
34
|
variant = "primary",
|
|
29
35
|
class: cls = "",
|
|
36
|
+
label = null,
|
|
37
|
+
aside,
|
|
38
|
+
error = null,
|
|
39
|
+
description = null,
|
|
40
|
+
id,
|
|
41
|
+
required = false,
|
|
30
42
|
...rest
|
|
31
43
|
}: {
|
|
32
44
|
checked?: boolean;
|
|
@@ -36,6 +48,11 @@
|
|
|
36
48
|
/** The colour of the "checked" state (default primary). */
|
|
37
49
|
variant?: Variant;
|
|
38
50
|
class?: string;
|
|
51
|
+
/** Field chrome (see FieldChrome.spec.md). Label renders ABOVE. */
|
|
52
|
+
label?: string | null;
|
|
53
|
+
aside?: Snippet;
|
|
54
|
+
error?: string | Snippet | null;
|
|
55
|
+
description?: string | Snippet | null;
|
|
39
56
|
} & Omit<HTMLInputAttributes, "size" | "type" | "class"> = $props();
|
|
40
57
|
|
|
41
58
|
// Per size: visible box dimension. Same scale as icons (14 / 16 / 20 px) so the box aligns with
|
|
@@ -71,6 +88,14 @@
|
|
|
71
88
|
`color-mix(in srgb, var(--ui-color-background) 80%, var(${variantToken[variant]}))`,
|
|
72
89
|
);
|
|
73
90
|
|
|
91
|
+
// Field chrome wiring.
|
|
92
|
+
const generatedId = nextFieldId();
|
|
93
|
+
const controlId = $derived(id ?? generatedId);
|
|
94
|
+
const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
|
|
95
|
+
const hasError = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
|
|
96
|
+
const hasDesc = $derived(isSnippet(description) ? true : !!(description && String(description).trim()));
|
|
97
|
+
const hasChrome = $derived(!!label || !!aside || hasError || hasDesc);
|
|
98
|
+
|
|
74
99
|
let el = $state<HTMLInputElement>();
|
|
75
100
|
// `indeterminate` is a DOM property, not an attribute — sync it imperatively so the real
|
|
76
101
|
// checkbox reports the tri-state to a11y and form serialization.
|
|
@@ -79,53 +104,69 @@
|
|
|
79
104
|
});
|
|
80
105
|
</script>
|
|
81
106
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<!-- Real checkbox — invisible, covering the whole control so a click anywhere toggles it and
|
|
88
|
-
keyboard/focus/screen-reader behavior is native. -->
|
|
89
|
-
<input
|
|
90
|
-
bind:this={el}
|
|
91
|
-
type="checkbox"
|
|
92
|
-
bind:checked
|
|
93
|
-
{disabled}
|
|
94
|
-
class="peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
95
|
-
{...rest}
|
|
96
|
-
/>
|
|
97
|
-
<span
|
|
98
|
-
class="relative inline-flex items-center justify-center rounded-[3px] border transition-colors duration-150 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:[outline-color:var(--ui-color-primary)] {boxSize}"
|
|
99
|
-
style={boxStyle}
|
|
107
|
+
{#snippet controlMarkup(cid: string | undefined, describedBy: string | undefined)}
|
|
108
|
+
<label
|
|
109
|
+
class="relative inline-flex shrink-0 items-center justify-center {heightClass} {disabled
|
|
110
|
+
? 'cursor-not-allowed opacity-50'
|
|
111
|
+
: 'cursor-pointer'} {hasChrome ? 'self-start' : cls}"
|
|
100
112
|
>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
<!-- Real checkbox — invisible, covering the whole control so a click anywhere toggles it and
|
|
114
|
+
keyboard/focus/screen-reader behavior is native. -->
|
|
115
|
+
<input
|
|
116
|
+
bind:this={el}
|
|
117
|
+
type="checkbox"
|
|
118
|
+
bind:checked
|
|
119
|
+
{disabled}
|
|
120
|
+
{required}
|
|
121
|
+
id={cid}
|
|
122
|
+
aria-describedby={describedBy}
|
|
123
|
+
aria-invalid={hasError || undefined}
|
|
124
|
+
class="peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
125
|
+
{...rest}
|
|
126
|
+
/>
|
|
127
|
+
<span
|
|
128
|
+
class="relative inline-flex items-center justify-center rounded-[3px] border transition-colors duration-150 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:[outline-color:var(--ui-color-primary)] {boxSize}"
|
|
129
|
+
style={boxStyle}
|
|
130
|
+
>
|
|
131
|
+
{#if indeterminate}
|
|
132
|
+
<!-- Dash for indeterminate — mid-line, contrasting mark on the filled background. -->
|
|
133
|
+
<svg
|
|
134
|
+
viewBox="0 0 16 16"
|
|
135
|
+
class={iconSizeCls}
|
|
136
|
+
style="color: {markColor}"
|
|
137
|
+
fill="none"
|
|
138
|
+
stroke="currentColor"
|
|
139
|
+
stroke-width="2.5"
|
|
140
|
+
stroke-linecap="round"
|
|
141
|
+
>
|
|
142
|
+
<line x1="4" y1="8" x2="12" y2="8" />
|
|
143
|
+
</svg>
|
|
144
|
+
{:else if checked}
|
|
145
|
+
<!-- Checkmark — sits inside the filled box. Stroke uses currentColor; `markColor` picks white
|
|
116
146
|
on accent fills, the background colour on neutral/ghost (which are text-coloured). -->
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
</label>
|
|
147
|
+
<svg
|
|
148
|
+
viewBox="0 0 16 16"
|
|
149
|
+
class={iconSizeCls}
|
|
150
|
+
style="color: {markColor}"
|
|
151
|
+
fill="none"
|
|
152
|
+
stroke="currentColor"
|
|
153
|
+
stroke-width="2.5"
|
|
154
|
+
stroke-linecap="round"
|
|
155
|
+
stroke-linejoin="round"
|
|
156
|
+
>
|
|
157
|
+
<polyline points="3.5,8.5 6.5,11.5 12.5,5" />
|
|
158
|
+
</svg>
|
|
159
|
+
{/if}
|
|
160
|
+
</span>
|
|
161
|
+
</label>
|
|
162
|
+
{/snippet}
|
|
163
|
+
|
|
164
|
+
{#if hasChrome}
|
|
165
|
+
<FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} {size} class={cls}>
|
|
166
|
+
{#snippet control({ describedBy })}
|
|
167
|
+
{@render controlMarkup(controlId, describedBy)}
|
|
168
|
+
{/snippet}
|
|
169
|
+
</FieldChrome>
|
|
170
|
+
{:else}
|
|
171
|
+
{@render controlMarkup(undefined, undefined)}
|
|
172
|
+
{/if}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HTMLInputAttributes } from "svelte/elements";
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
2
3
|
import type { Size } from "../types/sizes.js";
|
|
3
4
|
import { type Responsive } from "../types/responsive.js";
|
|
4
5
|
import { type Variant } from "../types/variants.js";
|
|
@@ -10,6 +11,11 @@ type $$ComponentProps = {
|
|
|
10
11
|
/** The colour of the "checked" state (default primary). */
|
|
11
12
|
variant?: Variant;
|
|
12
13
|
class?: string;
|
|
14
|
+
/** Field chrome (see FieldChrome.spec.md). Label renders ABOVE. */
|
|
15
|
+
label?: string | null;
|
|
16
|
+
aside?: Snippet;
|
|
17
|
+
error?: string | Snippet | null;
|
|
18
|
+
description?: string | Snippet | null;
|
|
13
19
|
} & Omit<HTMLInputAttributes, "size" | "type" | "class">;
|
|
14
20
|
declare const Checkbox: import("svelte").Component<$$ComponentProps, {}, "checked">;
|
|
15
21
|
type Checkbox = ReturnType<typeof Checkbox>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
// Interim SSR-stable id source (Svelte has no built-in useId). A module counter is deterministic
|
|
3
|
+
// per render order, so SSR + hydration agree as long as fields render in the same order — good
|
|
4
|
+
// enough until we adopt a first-class scheme. Consumers can always pass an explicit `id`.
|
|
5
|
+
let _uid = 0;
|
|
6
|
+
export function nextFieldId(): string {
|
|
7
|
+
return `ui-field-${++_uid}`;
|
|
8
|
+
}
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import type { Snippet } from "svelte";
|
|
13
|
+
import type { Size } from "../types/sizes.js";
|
|
14
|
+
import { responsiveClasses, type Responsive } from "../types/responsive.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* FieldChrome — the shared label/aside/error/description shell for every field-shaped component
|
|
18
|
+
* (Input, Select, TextArea, Checkbox, Toggle, Radio). See FieldChrome.spec.md.
|
|
19
|
+
*
|
|
20
|
+
* Renders, top to bottom (each row omitted when empty):
|
|
21
|
+
* [ label{*} ⟷ aside ] label row (flex justify-between; shown iff label || aside)
|
|
22
|
+
* { control() } the actual field, via the `control` snippet
|
|
23
|
+
* { error } --ui-color-error; string → {@html}
|
|
24
|
+
* { description } muted; string → {@html} (author-controlled markup only)
|
|
25
|
+
*
|
|
26
|
+
* The field owns its control + its error *state* (border/aria-invalid) — this only renders the
|
|
27
|
+
* chrome and hands the control snippet the `aria-describedby` string that links error/description.
|
|
28
|
+
*/
|
|
29
|
+
interface Props {
|
|
30
|
+
label?: string | null;
|
|
31
|
+
aside?: Snippet;
|
|
32
|
+
error?: string | Snippet | null;
|
|
33
|
+
description?: string | Snippet | null;
|
|
34
|
+
required?: boolean;
|
|
35
|
+
/** Resolved control id (the field passes its `id ?? nextFieldId()`). Wires `<label for>` + aria. */
|
|
36
|
+
controlId: string;
|
|
37
|
+
size?: Responsive<Size>;
|
|
38
|
+
class?: string;
|
|
39
|
+
/** Renders the control; receives the `aria-describedby` string to apply to the input. */
|
|
40
|
+
control: Snippet<[{ describedBy: string | undefined }]>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let {
|
|
44
|
+
label = null,
|
|
45
|
+
aside,
|
|
46
|
+
error = null,
|
|
47
|
+
description = null,
|
|
48
|
+
required = false,
|
|
49
|
+
controlId,
|
|
50
|
+
size = "md",
|
|
51
|
+
class: cls = "",
|
|
52
|
+
control,
|
|
53
|
+
}: Props = $props();
|
|
54
|
+
|
|
55
|
+
// Label tracks the size axis; error/description sit one tier smaller.
|
|
56
|
+
const labelTextMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
|
|
57
|
+
const subTextMap: Record<Size, string> = { sm: "text-[0.6875rem]", md: "text-xs", lg: "text-sm" };
|
|
58
|
+
const labelText = $derived(responsiveClasses(size, labelTextMap));
|
|
59
|
+
const subText = $derived(responsiveClasses(size, subTextMap));
|
|
60
|
+
|
|
61
|
+
const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
|
|
62
|
+
const hasError = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
|
|
63
|
+
const hasDesc = $derived(isSnippet(description) ? true : !!(description && String(description).trim()));
|
|
64
|
+
|
|
65
|
+
const errorId = $derived(`${controlId}-error`);
|
|
66
|
+
const descId = $derived(`${controlId}-desc`);
|
|
67
|
+
const describedBy = $derived(
|
|
68
|
+
[hasError ? errorId : null, hasDesc ? descId : null].filter(Boolean).join(" ") || undefined,
|
|
69
|
+
);
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<div class="flex w-full flex-col gap-1 {cls}">
|
|
73
|
+
{#if label || aside}
|
|
74
|
+
<div class="flex items-center justify-between gap-2">
|
|
75
|
+
{#if label}
|
|
76
|
+
<label
|
|
77
|
+
for={controlId}
|
|
78
|
+
class="{labelText} truncate font-medium [color:var(--ui-color-text)]"
|
|
79
|
+
>
|
|
80
|
+
{label}{#if required}<span aria-hidden="true" class="[color:var(--ui-color-error)]"> *</span
|
|
81
|
+
>{/if}
|
|
82
|
+
</label>
|
|
83
|
+
{:else}
|
|
84
|
+
<span></span>
|
|
85
|
+
{/if}
|
|
86
|
+
{#if aside}
|
|
87
|
+
<div class="{subText} flex shrink-0 items-center gap-2">{@render aside()}</div>
|
|
88
|
+
{/if}
|
|
89
|
+
</div>
|
|
90
|
+
{/if}
|
|
91
|
+
|
|
92
|
+
{@render control({ describedBy })}
|
|
93
|
+
|
|
94
|
+
{#if hasError}
|
|
95
|
+
<div id={errorId} class="{subText} [color:var(--ui-color-error)]">
|
|
96
|
+
{#if isSnippet(error)}{@render error()}{:else}{@html error}{/if}
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
{#if hasDesc}
|
|
100
|
+
<div id={descId} class="{subText} [color:color-mix(in_srgb,var(--ui-color-text)_65%,transparent)]">
|
|
101
|
+
{#if isSnippet(description)}{@render description()}{:else}{@html description}{/if}
|
|
102
|
+
</div>
|
|
103
|
+
{/if}
|
|
104
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare function nextFieldId(): string;
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { Size } from "../types/sizes.js";
|
|
4
|
+
import { type Responsive } from "../types/responsive.js";
|
|
5
|
+
/**
|
|
6
|
+
* FieldChrome — the shared label/aside/error/description shell for every field-shaped component
|
|
7
|
+
* (Input, Select, TextArea, Checkbox, Toggle, Radio). See FieldChrome.spec.md.
|
|
8
|
+
*
|
|
9
|
+
* Renders, top to bottom (each row omitted when empty):
|
|
10
|
+
* [ label{*} ⟷ aside ] label row (flex justify-between; shown iff label || aside)
|
|
11
|
+
* { control() } the actual field, via the `control` snippet
|
|
12
|
+
* { error } --ui-color-error; string → {@html}
|
|
13
|
+
* { description } muted; string → {@html} (author-controlled markup only)
|
|
14
|
+
*
|
|
15
|
+
* The field owns its control + its error *state* (border/aria-invalid) — this only renders the
|
|
16
|
+
* chrome and hands the control snippet the `aria-describedby` string that links error/description.
|
|
17
|
+
*/
|
|
18
|
+
interface Props {
|
|
19
|
+
label?: string | null;
|
|
20
|
+
aside?: Snippet;
|
|
21
|
+
error?: string | Snippet | null;
|
|
22
|
+
description?: string | Snippet | null;
|
|
23
|
+
required?: boolean;
|
|
24
|
+
/** Resolved control id (the field passes its `id ?? nextFieldId()`). Wires `<label for>` + aria. */
|
|
25
|
+
controlId: string;
|
|
26
|
+
size?: Responsive<Size>;
|
|
27
|
+
class?: string;
|
|
28
|
+
/** Renders the control; receives the `aria-describedby` string to apply to the input. */
|
|
29
|
+
control: Snippet<[{
|
|
30
|
+
describedBy: string | undefined;
|
|
31
|
+
}]>;
|
|
32
|
+
}
|
|
33
|
+
declare const FieldChrome: import("svelte").Component<Props, {}, "">;
|
|
34
|
+
type FieldChrome = ReturnType<typeof FieldChrome>;
|
|
35
|
+
export default FieldChrome;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { Size } from "../types/sizes.js";
|
|
5
5
|
import { responsiveClasses, type Responsive } from "../types/responsive.js";
|
|
6
6
|
import type { Variant } from "../types/variants.js";
|
|
7
|
+
import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
|
|
7
8
|
|
|
8
9
|
// Omit the native `size` attribute (character width, a number) — we repurpose `size` as the
|
|
9
10
|
// shared sm/md/lg axis; the native one is meaningless on our flex-based input anyway.
|
|
@@ -33,6 +34,16 @@
|
|
|
33
34
|
valid?: boolean;
|
|
34
35
|
touched?: boolean;
|
|
35
36
|
element?: HTMLInputElement;
|
|
37
|
+
/** The bordered field box (bindable) — lets a wrapper (e.g. Select) anchor an overlay to the
|
|
38
|
+
* field itself rather than the whole chrome stack. */
|
|
39
|
+
fieldElement?: HTMLDivElement;
|
|
40
|
+
/** Field chrome (see FieldChrome.spec.md). `label` renders above; `aside` is a right-aligned
|
|
41
|
+
* snippet in the label row; `error` (string → {@html}) is the inline error row; `description`
|
|
42
|
+
* (string → {@html}) is the muted help row. `required`/`id` inherited from input attrs. */
|
|
43
|
+
label?: string | null;
|
|
44
|
+
aside?: Snippet;
|
|
45
|
+
error?: string | Snippet | null;
|
|
46
|
+
description?: string | Snippet | null;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
let {
|
|
@@ -51,9 +62,18 @@
|
|
|
51
62
|
valid = $bindable(true),
|
|
52
63
|
touched = $bindable(false),
|
|
53
64
|
element = $bindable(),
|
|
65
|
+
fieldElement = $bindable(),
|
|
54
66
|
type = "text",
|
|
55
67
|
disabled = false,
|
|
56
68
|
readonly = false,
|
|
69
|
+
// Field chrome. `id`/`required` are pulled out of the inherited input attrs so we can wire the
|
|
70
|
+
// label + aria and re-apply them explicitly on the <input>.
|
|
71
|
+
label = null,
|
|
72
|
+
aside,
|
|
73
|
+
error = null,
|
|
74
|
+
description = null,
|
|
75
|
+
id,
|
|
76
|
+
required = false,
|
|
57
77
|
// Pulled out of restProps so the wrapping handlers below can't be clobbered by the
|
|
58
78
|
// `{...restProps}` spread on the <input>; we still forward the consumer's callbacks.
|
|
59
79
|
oninput: oninputProp,
|
|
@@ -61,6 +81,17 @@
|
|
|
61
81
|
...restProps
|
|
62
82
|
}: Props = $props();
|
|
63
83
|
|
|
84
|
+
// Resolve the control id for label `for` + aria wiring: consumer-provided, else a stable generated
|
|
85
|
+
// one (generated ONCE — deriving `nextFieldId()` would remint it every render).
|
|
86
|
+
const generatedId = nextFieldId();
|
|
87
|
+
const controlId = $derived(id ?? generatedId);
|
|
88
|
+
|
|
89
|
+
const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
|
|
90
|
+
// Chrome `error` present? (drives the shared error STATE — border + aria-invalid — alongside a
|
|
91
|
+
// failing `validate`; the message channels differ: error → inline row, validate → native tooltip.)
|
|
92
|
+
const hasErrorProp = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
|
|
93
|
+
const invalid = $derived(hasErrorProp || (touched && !valid));
|
|
94
|
+
|
|
64
95
|
let inputElement: HTMLInputElement;
|
|
65
96
|
|
|
66
97
|
// Sync element binding
|
|
@@ -143,11 +174,6 @@
|
|
|
143
174
|
const svgClass = $derived(responsiveClasses(size, svgMap));
|
|
144
175
|
|
|
145
176
|
// Computed classes
|
|
146
|
-
let containerClasses = $derived.by(() => {
|
|
147
|
-
const base = "relative w-full";
|
|
148
|
-
return `${base} ${containerClass}`.trim();
|
|
149
|
-
});
|
|
150
|
-
|
|
151
177
|
let wrapperClasses = $derived.by(() => {
|
|
152
178
|
const base = `w-full ${heightClass} flex items-center [border-radius:var(--ui-border-radius)]`;
|
|
153
179
|
|
|
@@ -170,8 +196,12 @@
|
|
|
170
196
|
// surface stays uniform under any theme). `borderless` overrides it with transparent (the field's
|
|
171
197
|
// own background) — the 1px box stays, so the field never jumps, and the focus outline still fires.
|
|
172
198
|
const frameStyles = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
|
|
199
|
+
// Field background follows the variant axis: `ghost` is transparent (chromeless); every other
|
|
200
|
+
// variant fills with --ui-color-background so the field reads as a field on a non-white page.
|
|
201
|
+
const fieldBg =
|
|
202
|
+
variant === "ghost" ? "bg-transparent" : "[background-color:var(--ui-color-background)]";
|
|
173
203
|
const themeStyles = [
|
|
174
|
-
|
|
204
|
+
fieldBg,
|
|
175
205
|
"border", // 1px — matches --ui-border (List/TextArea); uniform across the library
|
|
176
206
|
paddingClasses,
|
|
177
207
|
frameStyles,
|
|
@@ -179,9 +209,9 @@
|
|
|
179
209
|
"focus-within:[outline-offset:0px]",
|
|
180
210
|
].join(" ");
|
|
181
211
|
|
|
182
|
-
//
|
|
212
|
+
// Error state (shared): chrome `error` set OR a failing `validate` (once touched).
|
|
183
213
|
let validationStyles = "";
|
|
184
|
-
if (
|
|
214
|
+
if (invalid) {
|
|
185
215
|
validationStyles =
|
|
186
216
|
"[border-color:var(--ui-color-error)] focus-within:[ring-color:var(--ui-color-error)]";
|
|
187
217
|
}
|
|
@@ -281,9 +311,19 @@
|
|
|
281
311
|
</span>
|
|
282
312
|
{/snippet}
|
|
283
313
|
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
314
|
+
<FieldChrome
|
|
315
|
+
{label}
|
|
316
|
+
{aside}
|
|
317
|
+
{error}
|
|
318
|
+
{description}
|
|
319
|
+
required={!!required}
|
|
320
|
+
{controlId}
|
|
321
|
+
{size}
|
|
322
|
+
class={containerClass}
|
|
323
|
+
>
|
|
324
|
+
{#snippet control({ describedBy })}
|
|
325
|
+
<div bind:this={fieldElement} class="relative {wrapperClasses}" style="user-select: none;">
|
|
326
|
+
{#if icon && iconPosition === "left"}
|
|
287
327
|
{#if iconClickable}
|
|
288
328
|
<div
|
|
289
329
|
class={iconClasses}
|
|
@@ -305,18 +345,23 @@
|
|
|
305
345
|
{/if}
|
|
306
346
|
{/if}
|
|
307
347
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
348
|
+
<input
|
|
349
|
+
bind:this={inputElement}
|
|
350
|
+
{type}
|
|
351
|
+
{value}
|
|
352
|
+
{disabled}
|
|
353
|
+
{readonly}
|
|
354
|
+
oninput={handleInput}
|
|
355
|
+
onfocus={handleFocus}
|
|
356
|
+
{...restProps}
|
|
357
|
+
id={controlId}
|
|
358
|
+
{required}
|
|
359
|
+
aria-describedby={describedBy}
|
|
360
|
+
aria-invalid={invalid || undefined}
|
|
361
|
+
aria-required={required || undefined}
|
|
362
|
+
class="{inputClasses} ui-input"
|
|
363
|
+
style="border: none !important; user-select: text !important;"
|
|
364
|
+
/>
|
|
320
365
|
|
|
321
366
|
{#if icon && iconPosition === "right"}
|
|
322
367
|
{#if iconClickable}
|
|
@@ -340,13 +385,14 @@
|
|
|
340
385
|
{/if}
|
|
341
386
|
{/if}
|
|
342
387
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
388
|
+
{#if actions}
|
|
389
|
+
<div class="flex h-full shrink-0 items-center gap-1">
|
|
390
|
+
{@render actions()}
|
|
391
|
+
</div>
|
|
392
|
+
{/if}
|
|
393
|
+
</div>
|
|
394
|
+
{/snippet}
|
|
395
|
+
</FieldChrome>
|
|
350
396
|
|
|
351
397
|
<style>
|
|
352
398
|
/* Kill the UA focus ring/box-shadow without inline !important (which would also defeat the
|
|
@@ -29,7 +29,17 @@ interface Props extends Omit<HTMLInputAttributes, "size"> {
|
|
|
29
29
|
valid?: boolean;
|
|
30
30
|
touched?: boolean;
|
|
31
31
|
element?: HTMLInputElement;
|
|
32
|
+
/** The bordered field box (bindable) — lets a wrapper (e.g. Select) anchor an overlay to the
|
|
33
|
+
* field itself rather than the whole chrome stack. */
|
|
34
|
+
fieldElement?: HTMLDivElement;
|
|
35
|
+
/** Field chrome (see FieldChrome.spec.md). `label` renders above; `aside` is a right-aligned
|
|
36
|
+
* snippet in the label row; `error` (string → {@html}) is the inline error row; `description`
|
|
37
|
+
* (string → {@html}) is the muted help row. `required`/`id` inherited from input attrs. */
|
|
38
|
+
label?: string | null;
|
|
39
|
+
aside?: Snippet;
|
|
40
|
+
error?: string | Snippet | null;
|
|
41
|
+
description?: string | Snippet | null;
|
|
32
42
|
}
|
|
33
|
-
declare const Input: import("svelte").Component<Props, {}, "element" | "value" | "valid" | "touched">;
|
|
43
|
+
declare const Input: import("svelte").Component<Props, {}, "element" | "value" | "valid" | "touched" | "fieldElement">;
|
|
34
44
|
type Input = ReturnType<typeof Input>;
|
|
35
45
|
export default Input;
|
|
@@ -82,6 +82,9 @@
|
|
|
82
82
|
let isOpen = $state(false);
|
|
83
83
|
let containerElement = $state<HTMLElement>();
|
|
84
84
|
let inputElement = $state<HTMLInputElement>();
|
|
85
|
+
// The trigger Input's bordered field box — the dropdown anchors HERE (not the container), so field
|
|
86
|
+
// chrome forwarded to the trigger (label/description via ...restProps) doesn't shift the dropdown.
|
|
87
|
+
let triggerField = $state<HTMLDivElement>();
|
|
85
88
|
let listElement = $state<any>();
|
|
86
89
|
|
|
87
90
|
// Get display text for selected value
|
|
@@ -199,6 +202,7 @@
|
|
|
199
202
|
{/if}
|
|
200
203
|
<Input
|
|
201
204
|
bind:element={inputElement}
|
|
205
|
+
bind:fieldElement={triggerField}
|
|
202
206
|
value={selectedLabel()}
|
|
203
207
|
{placeholder}
|
|
204
208
|
readonly={true}
|
|
@@ -222,7 +226,7 @@
|
|
|
222
226
|
`anchored` engine — so it flips up/down against the viewport, is never clipped, and reserves
|
|
223
227
|
no layout space. Popup keeps the List mounted (hidden) while closed, preserving its cache.
|
|
224
228
|
-->
|
|
225
|
-
<Popup anchor={containerElement} bind:open={isOpen} matchWidth offset={4}>
|
|
229
|
+
<Popup anchor={triggerField ?? containerElement} bind:open={isOpen} matchWidth offset={4}>
|
|
226
230
|
<List
|
|
227
231
|
bind:this={listElement}
|
|
228
232
|
{options}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import ContentCopy from "~icons/mdi/content-copy";
|
|
3
3
|
import type { HTMLTextareaAttributes } from "svelte/elements";
|
|
4
|
+
import type { Snippet } from "svelte";
|
|
4
5
|
import type { Size } from "../types/sizes.js";
|
|
5
6
|
import { responsiveClasses, resolveScalar, type Responsive } from "../types/responsive.js";
|
|
6
7
|
import type { Variant } from "../types/variants.js";
|
|
7
8
|
import { DEV } from "esm-env";
|
|
9
|
+
import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
|
|
8
10
|
|
|
9
11
|
type TextAreaCopy = "none" | "selection" | "button";
|
|
10
12
|
|
|
@@ -29,6 +31,11 @@
|
|
|
29
31
|
/** Drop the visible frame while keeping the 1px box + focus outline (border → transparent). */
|
|
30
32
|
borderless?: boolean;
|
|
31
33
|
required?: boolean;
|
|
34
|
+
/** Field chrome (see FieldChrome.spec.md). Label renders ABOVE; error/description below. */
|
|
35
|
+
label?: string | null;
|
|
36
|
+
aside?: Snippet;
|
|
37
|
+
error?: string | Snippet | null;
|
|
38
|
+
description?: string | Snippet | null;
|
|
32
39
|
/** How the text can be copied — one exclusive mode:
|
|
33
40
|
* `'selection'` (default) — copy via native text selection only, no button;
|
|
34
41
|
* `'button'` — also show the copy-to-clipboard button;
|
|
@@ -95,12 +102,24 @@
|
|
|
95
102
|
variant = "secondary",
|
|
96
103
|
borderless = false,
|
|
97
104
|
required = false,
|
|
105
|
+
label = null,
|
|
106
|
+
aside,
|
|
107
|
+
error = null,
|
|
108
|
+
description = null,
|
|
109
|
+
id,
|
|
98
110
|
copy = "selection",
|
|
99
111
|
oncopy,
|
|
100
112
|
readonly = false,
|
|
101
113
|
...restProps
|
|
102
114
|
}: Props = $props();
|
|
103
115
|
|
|
116
|
+
// Field chrome wiring. Error state = chrome `error` set OR a failing `validate` (once touched).
|
|
117
|
+
const generatedId = nextFieldId();
|
|
118
|
+
const controlId = $derived(id ?? generatedId);
|
|
119
|
+
const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
|
|
120
|
+
const hasErrorProp = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
|
|
121
|
+
const invalid = $derived(hasErrorProp || (touched && !valid));
|
|
122
|
+
|
|
104
123
|
// Size axis (see types/sizes.ts): text size, wrapper padding, and the line-height the autosize
|
|
105
124
|
// math keys off (so smaller sizes aren't airy). An explicit lineHeightRem still wins.
|
|
106
125
|
const textMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
|
|
@@ -295,27 +314,26 @@
|
|
|
295
314
|
});
|
|
296
315
|
|
|
297
316
|
// Computed classes
|
|
298
|
-
let containerClasses = $derived.by(() => {
|
|
299
|
-
const base = "relative w-full";
|
|
300
|
-
return `${base} ${containerClass}`.trim();
|
|
301
|
-
});
|
|
302
|
-
|
|
303
317
|
let wrapperClasses = $derived.by(() => {
|
|
304
318
|
const base = `relative w-full [border-radius:var(--ui-border-radius)] border ${padClass}`;
|
|
305
319
|
|
|
306
320
|
// Border colour from the variant map (same calc as Input); `borderless` → transparent (its own
|
|
307
321
|
// background), keeping the 1px box + focus outline.
|
|
308
322
|
const frameStyles = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
|
|
323
|
+
// Field background follows the variant axis: `ghost` transparent; every other variant fills with
|
|
324
|
+
// --ui-color-background so the field reads as a field on a non-white surface.
|
|
325
|
+
const fieldBg =
|
|
326
|
+
variant === "ghost" ? "bg-transparent" : "[background-color:var(--ui-color-background)]";
|
|
309
327
|
const themeStyles = [
|
|
310
|
-
|
|
328
|
+
fieldBg,
|
|
311
329
|
frameStyles,
|
|
312
330
|
"focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)]",
|
|
313
331
|
"focus-within:[outline-offset:0px]",
|
|
314
332
|
].join(" ");
|
|
315
333
|
|
|
316
|
-
//
|
|
334
|
+
// Error state (shared): chrome `error` set OR a failing `validate` (once touched).
|
|
317
335
|
let validationStyles = "";
|
|
318
|
-
if (
|
|
336
|
+
if (invalid) {
|
|
319
337
|
validationStyles =
|
|
320
338
|
"[border-color:var(--ui-color-error)] focus-within:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-error)_70%,transparent)]";
|
|
321
339
|
}
|
|
@@ -660,11 +678,21 @@
|
|
|
660
678
|
});
|
|
661
679
|
</script>
|
|
662
680
|
|
|
663
|
-
<
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
681
|
+
<FieldChrome
|
|
682
|
+
{label}
|
|
683
|
+
{aside}
|
|
684
|
+
{error}
|
|
685
|
+
{description}
|
|
686
|
+
required={!!required}
|
|
687
|
+
{controlId}
|
|
688
|
+
{size}
|
|
689
|
+
class={containerClass}
|
|
690
|
+
>
|
|
691
|
+
{#snippet control({ describedBy })}
|
|
692
|
+
<div
|
|
693
|
+
bind:this={containerElement}
|
|
694
|
+
class={wrapperClasses}
|
|
695
|
+
onmouseenter={effectiveReadonly
|
|
668
696
|
? undefined
|
|
669
697
|
: () => {
|
|
670
698
|
hovered = true;
|
|
@@ -703,11 +731,10 @@
|
|
|
703
731
|
{disabled}
|
|
704
732
|
readonly={effectiveReadonly}
|
|
705
733
|
{required}
|
|
734
|
+
id={controlId}
|
|
706
735
|
tabindex={effectiveSelectable ? undefined : -1}
|
|
707
|
-
aria-invalid={
|
|
708
|
-
aria-describedby={
|
|
709
|
-
? "textarea-error"
|
|
710
|
-
: undefined}></textarea>
|
|
736
|
+
aria-invalid={invalid ? "true" : undefined}
|
|
737
|
+
aria-describedby={describedBy}></textarea>
|
|
711
738
|
|
|
712
739
|
{#if effectiveCopyable && effectiveContent}
|
|
713
740
|
<div
|
|
@@ -750,8 +777,9 @@
|
|
|
750
777
|
aria-label="Scroll thumb"
|
|
751
778
|
></div>
|
|
752
779
|
{/if}
|
|
753
|
-
|
|
754
|
-
|
|
780
|
+
</div>
|
|
781
|
+
{/snippet}
|
|
782
|
+
</FieldChrome>
|
|
755
783
|
|
|
756
784
|
<style>
|
|
757
785
|
/* Hide default scrollbar */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HTMLTextareaAttributes } from "svelte/elements";
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
2
3
|
import type { Size } from "../types/sizes.js";
|
|
3
4
|
import { type Responsive } from "../types/responsive.js";
|
|
4
5
|
import type { Variant } from "../types/variants.js";
|
|
@@ -22,6 +23,11 @@ interface Props extends Omit<HTMLTextareaAttributes, "oncopy"> {
|
|
|
22
23
|
/** Drop the visible frame while keeping the 1px box + focus outline (border → transparent). */
|
|
23
24
|
borderless?: boolean;
|
|
24
25
|
required?: boolean;
|
|
26
|
+
/** Field chrome (see FieldChrome.spec.md). Label renders ABOVE; error/description below. */
|
|
27
|
+
label?: string | null;
|
|
28
|
+
aside?: Snippet;
|
|
29
|
+
error?: string | Snippet | null;
|
|
30
|
+
description?: string | Snippet | null;
|
|
25
31
|
/** How the text can be copied — one exclusive mode:
|
|
26
32
|
* `'selection'` (default) — copy via native text selection only, no button;
|
|
27
33
|
* `'button'` — also show the copy-to-clipboard button;
|
|
@@ -12,13 +12,19 @@
|
|
|
12
12
|
* with the switch centred inside. So a Toggle dropped next to `size`-matched Inputs/Selects lines up on
|
|
13
13
|
* the same baseline (and the full-height wrapper is the click target), while the switch itself stays
|
|
14
14
|
* switch-sized rather than ballooning to the input height.
|
|
15
|
+
*
|
|
16
|
+
* FIELD CHROME: `label`/`aside`/`error`/`description` (see FieldChrome.spec.md). When any is set the
|
|
17
|
+
* control is wrapped in the shared FieldChrome (label ABOVE, error/description below); with none set it
|
|
18
|
+
* renders bare + inline exactly as before (zero-regression).
|
|
15
19
|
*/
|
|
16
20
|
-->
|
|
17
21
|
<script lang="ts">
|
|
18
22
|
import type { HTMLInputAttributes } from "svelte/elements";
|
|
23
|
+
import type { Snippet } from "svelte";
|
|
19
24
|
import type { Size } from "../types/sizes.js";
|
|
20
25
|
import { responsiveClasses, resolveScalar, type Responsive } from "../types/responsive.js";
|
|
21
26
|
import { variantToken, type Variant } from "../types/variants.js";
|
|
27
|
+
import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
|
|
22
28
|
|
|
23
29
|
let {
|
|
24
30
|
checked = $bindable(false),
|
|
@@ -26,6 +32,12 @@
|
|
|
26
32
|
size = "md",
|
|
27
33
|
variant = "primary",
|
|
28
34
|
class: cls = "",
|
|
35
|
+
label = null,
|
|
36
|
+
aside,
|
|
37
|
+
error = null,
|
|
38
|
+
description = null,
|
|
39
|
+
id,
|
|
40
|
+
required = false,
|
|
29
41
|
...rest
|
|
30
42
|
}: {
|
|
31
43
|
checked?: boolean;
|
|
@@ -34,6 +46,11 @@
|
|
|
34
46
|
/** The colour of the "on" state (default primary). */
|
|
35
47
|
variant?: Variant;
|
|
36
48
|
class?: string;
|
|
49
|
+
/** Field chrome (see FieldChrome.spec.md). Label renders ABOVE. */
|
|
50
|
+
label?: string | null;
|
|
51
|
+
aside?: Snippet;
|
|
52
|
+
error?: string | Snippet | null;
|
|
53
|
+
description?: string | Snippet | null;
|
|
37
54
|
} & Omit<HTMLInputAttributes, "size" | "type" | "class"> = $props();
|
|
38
55
|
|
|
39
56
|
// Per size: track w/h + knob size are CLASSES (responsive); knob travel is an inline-style transform
|
|
@@ -66,28 +83,52 @@
|
|
|
66
83
|
const knobStyle = $derived(
|
|
67
84
|
`transform: ${checked ? onMap[resolveScalar(size, "md")] : "translateX(2px)"}; background-color: ${knobColor}`,
|
|
68
85
|
);
|
|
86
|
+
|
|
87
|
+
// Field chrome wiring.
|
|
88
|
+
const generatedId = nextFieldId();
|
|
89
|
+
const controlId = $derived(id ?? generatedId);
|
|
90
|
+
const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
|
|
91
|
+
const hasError = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
|
|
92
|
+
const hasDesc = $derived(isSnippet(description) ? true : !!(description && String(description).trim()));
|
|
93
|
+
const hasChrome = $derived(!!label || !!aside || hasError || hasDesc);
|
|
69
94
|
</script>
|
|
70
95
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<!-- Real checkbox, invisible but covering the whole control so a click anywhere toggles it. -->
|
|
77
|
-
<input
|
|
78
|
-
type="checkbox"
|
|
79
|
-
bind:checked
|
|
80
|
-
{disabled}
|
|
81
|
-
class="peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
82
|
-
{...rest}
|
|
83
|
-
/>
|
|
84
|
-
<span
|
|
85
|
-
class="relative inline-flex items-center rounded-full transition-colors duration-150 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:[outline-color:var(--ui-color-primary)] {trackCls}"
|
|
86
|
-
style={trackStyle}
|
|
96
|
+
{#snippet controlMarkup(cid: string | undefined, describedBy: string | undefined)}
|
|
97
|
+
<label
|
|
98
|
+
class="relative inline-flex shrink-0 items-center {heightClass} {disabled
|
|
99
|
+
? 'cursor-not-allowed opacity-50'
|
|
100
|
+
: 'cursor-pointer'} {hasChrome ? 'self-start' : cls}"
|
|
87
101
|
>
|
|
102
|
+
<!-- Real checkbox, invisible but covering the whole control so a click anywhere toggles it. -->
|
|
103
|
+
<input
|
|
104
|
+
type="checkbox"
|
|
105
|
+
bind:checked
|
|
106
|
+
{disabled}
|
|
107
|
+
{required}
|
|
108
|
+
id={cid}
|
|
109
|
+
aria-describedby={describedBy}
|
|
110
|
+
aria-invalid={hasError || undefined}
|
|
111
|
+
class="peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
112
|
+
{...rest}
|
|
113
|
+
/>
|
|
88
114
|
<span
|
|
89
|
-
class="inline-
|
|
90
|
-
style={
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
class="relative inline-flex items-center rounded-full transition-colors duration-150 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:[outline-color:var(--ui-color-primary)] {trackCls}"
|
|
116
|
+
style={trackStyle}
|
|
117
|
+
>
|
|
118
|
+
<span
|
|
119
|
+
class="inline-block rounded-full shadow transition-transform duration-150 {knobCls}"
|
|
120
|
+
style={knobStyle}
|
|
121
|
+
></span>
|
|
122
|
+
</span>
|
|
123
|
+
</label>
|
|
124
|
+
{/snippet}
|
|
125
|
+
|
|
126
|
+
{#if hasChrome}
|
|
127
|
+
<FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} {size} class={cls}>
|
|
128
|
+
{#snippet control({ describedBy })}
|
|
129
|
+
{@render controlMarkup(controlId, describedBy)}
|
|
130
|
+
{/snippet}
|
|
131
|
+
</FieldChrome>
|
|
132
|
+
{:else}
|
|
133
|
+
{@render controlMarkup(undefined, undefined)}
|
|
134
|
+
{/if}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HTMLInputAttributes } from "svelte/elements";
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
2
3
|
import type { Size } from "../types/sizes.js";
|
|
3
4
|
import { type Responsive } from "../types/responsive.js";
|
|
4
5
|
import { type Variant } from "../types/variants.js";
|
|
@@ -9,6 +10,11 @@ type $$ComponentProps = {
|
|
|
9
10
|
/** The colour of the "on" state (default primary). */
|
|
10
11
|
variant?: Variant;
|
|
11
12
|
class?: string;
|
|
13
|
+
/** Field chrome (see FieldChrome.spec.md). Label renders ABOVE. */
|
|
14
|
+
label?: string | null;
|
|
15
|
+
aside?: Snippet;
|
|
16
|
+
error?: string | Snippet | null;
|
|
17
|
+
description?: string | Snippet | null;
|
|
12
18
|
} & Omit<HTMLInputAttributes, "size" | "type" | "class">;
|
|
13
19
|
declare const Toggle: import("svelte").Component<$$ComponentProps, {}, "checked">;
|
|
14
20
|
type Toggle = ReturnType<typeof Toggle>;
|
package/docs/components.md
CHANGED
|
@@ -17,6 +17,30 @@ Most sizeable/themeable components share two props (defined in `types/variants.t
|
|
|
17
17
|
- **`size`** — `sm` (24px), `md` (32px, **default**), `lg` (40px). Tied to the theme's
|
|
18
18
|
`--ui-height-sm` / `--ui-height` / `--ui-height-lg`.
|
|
19
19
|
|
|
20
|
+
## Field chrome (shared)
|
|
21
|
+
|
|
22
|
+
Every field-shaped component — `Input`, `Select`, `TextArea`, `Checkbox`, `Toggle` (and the
|
|
23
|
+
specialized inputs via `Input`) — accepts a shared label/help/error frame, rendered by one internal
|
|
24
|
+
`FieldChrome` (source of truth: `src/lib/components/FieldChrome.spec.md`). With none set, the field
|
|
25
|
+
renders exactly as before (zero-regression).
|
|
26
|
+
|
|
27
|
+
- **`label`** (`string | null`) — rendered **above** the control, left-aligned (uniform for all
|
|
28
|
+
controls, so mixed-control rows at the same `size` align pixel-perfect). For other placements, omit
|
|
29
|
+
and supply your own `<label>`.
|
|
30
|
+
- **`aside`** (`Snippet`) — right-aligned in the label row (char count, status, link, button).
|
|
31
|
+
- **`error`** (`string | Snippet | null`) — inline error row (string → `{@html}`); also flips the
|
|
32
|
+
error border + `aria-invalid`. Coexists with `description`.
|
|
33
|
+
- **`description`** (`string | Snippet | null`) — muted help row below the error (string → `{@html}`,
|
|
34
|
+
author-controlled markup only).
|
|
35
|
+
- **`required`** (`boolean`) — appends `*` to the label + `aria-required`.
|
|
36
|
+
- **`id`** (`string`) — auto-generated (SSR-stable) if omitted; wires `<label for>` + `aria-describedby`.
|
|
37
|
+
|
|
38
|
+
`validate` (on `Input`/`TextArea`) is separate: it surfaces via the **native validity tooltip** (on
|
|
39
|
+
form submit) and shares the error border; the controlled `error` prop is the inline row.
|
|
40
|
+
|
|
41
|
+
Field **background** follows the variant axis: `ghost` → transparent, every other variant →
|
|
42
|
+
`--ui-color-background`.
|
|
43
|
+
|
|
20
44
|
## Form Components
|
|
21
45
|
|
|
22
46
|
### Button
|
package/docs/usage.md
CHANGED
|
@@ -77,6 +77,45 @@ into a component's `icon` snippet — the library sizes and colors it:
|
|
|
77
77
|
|
|
78
78
|
## Component Examples
|
|
79
79
|
|
|
80
|
+
### Field chrome (label / help / error)
|
|
81
|
+
|
|
82
|
+
Every field-shaped component renders its own label + help/error frame — don't hand-wrap fields.
|
|
83
|
+
|
|
84
|
+
```svelte
|
|
85
|
+
<script>
|
|
86
|
+
import { Input, Select, Toggle, Checkbox } from "@spaethtech/svelte-ui";
|
|
87
|
+
|
|
88
|
+
let slug = $state("");
|
|
89
|
+
let slugTaken = $state(true);
|
|
90
|
+
let role = $state("editor");
|
|
91
|
+
let notify = $state(true);
|
|
92
|
+
let agree = $state(false);
|
|
93
|
+
const roles = [
|
|
94
|
+
{ value: "admin", label: "Admin" },
|
|
95
|
+
{ value: "editor", label: "Editor" },
|
|
96
|
+
];
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<!-- label above, muted description below, controlled error, char-count aside, required -->
|
|
100
|
+
<Input
|
|
101
|
+
label="Slug"
|
|
102
|
+
required
|
|
103
|
+
description="Lowercase, hyphens only."
|
|
104
|
+
error={slugTaken ? "That slug is already taken." : null}
|
|
105
|
+
bind:value={slug}
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<!-- Mixed controls in a row all align on the same label row + control baseline -->
|
|
109
|
+
<div class="grid grid-cols-3 gap-4 items-start">
|
|
110
|
+
<Select label="Role" options={roles} bind:value={role} />
|
|
111
|
+
<Toggle label="Notifications" bind:checked={notify} />
|
|
112
|
+
<Checkbox label="Agree to terms" bind:checked={agree} required />
|
|
113
|
+
</div>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`validate` (on `Input`/`TextArea`) is separate — it shows the **native** validity tooltip on submit;
|
|
117
|
+
the `error` prop is the inline row. Both flip the error border.
|
|
118
|
+
|
|
80
119
|
### Button
|
|
81
120
|
|
|
82
121
|
```svelte
|