@spaethtech/svelte-ui 0.4.1-dev.20.b3f6fb8 → 0.4.1-dev.21.2ccc703

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.
@@ -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**;
@@ -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
- <label
83
- class="relative inline-flex shrink-0 items-center justify-center {heightClass} {disabled
84
- ? 'cursor-not-allowed opacity-50'
85
- : 'cursor-pointer'} {cls}"
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
- {#if indeterminate}
102
- <!-- Dash for indeterminate — mid-line, contrasting mark on the filled background. -->
103
- <svg
104
- viewBox="0 0 16 16"
105
- class={iconSizeCls}
106
- style="color: {markColor}"
107
- fill="none"
108
- stroke="currentColor"
109
- stroke-width="2.5"
110
- stroke-linecap="round"
111
- >
112
- <line x1="4" y1="8" x2="12" y2="8" />
113
- </svg>
114
- {:else if checked}
115
- <!-- Checkmark — sits inside the filled box. Stroke uses currentColor; `markColor` picks white
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
- <svg
118
- viewBox="0 0 16 16"
119
- class={iconSizeCls}
120
- style="color: {markColor}"
121
- fill="none"
122
- stroke="currentColor"
123
- stroke-width="2.5"
124
- stroke-linecap="round"
125
- stroke-linejoin="round"
126
- >
127
- <polyline points="3.5,8.5 6.5,11.5 12.5,5" />
128
- </svg>
129
- {/if}
130
- </span>
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
- "bg-transparent",
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
- // Validation state overrides
212
+ // Error state (shared): chrome `error` set OR a failing `validate` (once touched).
183
213
  let validationStyles = "";
184
- if (touched && !valid) {
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
- <div class={containerClasses}>
285
- <div class={wrapperClasses} style="user-select: none;">
286
- {#if icon && iconPosition === "left"}
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
- <input
309
- bind:this={inputElement}
310
- {type}
311
- {value}
312
- {disabled}
313
- {readonly}
314
- oninput={handleInput}
315
- onfocus={handleFocus}
316
- {...restProps}
317
- class="{inputClasses} ui-input"
318
- style="border: none !important; user-select: text !important;"
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
- {#if actions}
344
- <div class="flex h-full shrink-0 items-center gap-1">
345
- {@render actions()}
346
- </div>
347
- {/if}
348
- </div>
349
- </div>
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
- "bg-transparent",
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
- // Validation state overrides
334
+ // Error state (shared): chrome `error` set OR a failing `validate` (once touched).
317
335
  let validationStyles = "";
318
- if (touched && !valid) {
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
- <div class={containerClasses}>
664
- <div
665
- bind:this={containerElement}
666
- class={wrapperClasses}
667
- onmouseenter={effectiveReadonly
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={touched && !valid ? "true" : "false"}
708
- aria-describedby={touched && !valid && typeof validationResult === "string"
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
- </div>
754
- </div>
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
- <label
72
- class="relative inline-flex shrink-0 items-center {heightClass} {disabled
73
- ? 'cursor-not-allowed opacity-50'
74
- : 'cursor-pointer'} {cls}"
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-block rounded-full shadow transition-transform duration-150 {knobCls}"
90
- style={knobStyle}
91
- ></span>
92
- </span>
93
- </label>
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>;
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.4.1-dev.20.b3f6fb8",
3
+ "version": "0.4.1-dev.21.2ccc703",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"