@spaethtech/svelte-ui 0.4.1-dev.21.2ccc703 → 0.4.1-dev.23.372ce0b

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.
@@ -98,6 +98,18 @@ control.
98
98
  surfaces through the **native validity tooltip** (shown on form submit); the controlled **`error`**
99
99
  prop is the inline row. Both flip the field's error border.
100
100
 
101
+ **Groups:** for a set of radios/checkboxes/toggles under one legend, wrap them in **`FieldGroup`**
102
+ (same `label`/`description`/`error`/`required` vocab on a `<fieldset>`, plus `orientation`). Inside a
103
+ group, item labels render **inline** (right of the control). For a radio group, `bind:value` on the
104
+ `FieldGroup` (+ optional `name`) and the child `<Radio>`s share it automatically — no `bind:group`:
105
+
106
+ ```svelte
107
+ <FieldGroup label="Delivery" bind:value={method} name="method">
108
+ <Radio value="email" label="Email" />
109
+ <Radio value="sms" label="SMS" />
110
+ </FieldGroup>
111
+ ```
112
+
101
113
  ## Icons — no `<Icon>` component
102
114
 
103
115
  Import an MDI icon component from `~icons/mdi/*` and render it into a component's **`icon` snippet**;
@@ -137,7 +149,8 @@ Mount `<Toaster />` once at the app root, then push from anywhere with `toast.su
137
149
  All from `@spaethtech/svelte-ui` (see the shipped `docs/components.md` + `docs/usage.md` for props +
138
150
  examples):
139
151
 
140
- - **Form:** `Button` `ButtonDropdown` `Input` `Select` `List` `TextArea` `Checkbox` `Toggle` `Rating`
152
+ - **Form:** `Button` `ButtonDropdown` `Input` `Select` `List` `TextArea` `Checkbox` `Toggle` `Radio`
153
+ `Rating` · **`FieldGroup`** (fieldset wrapper for radio/checkbox/toggle sets)
141
154
  - **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput`
142
155
  - **Date / time:** `DatePicker` `Calendar` `TimePicker` `TimeSpinner` `TimeRangeInput` `DateTimeInput`
143
156
  - **Data:** `DataTable` `Query` — driven by the headless layer at **`@spaethtech/svelte-ui/data`**
@@ -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:
@@ -1,21 +1,13 @@
1
1
  <!--
2
2
  /**
3
- * Checkbox — a themed multi-state box (unchecked / checked / indeterminate). Use this, NOT a Toggle,
4
- * for selection semantics: a row is / isn't selected, a rule does / doesn't apply, a filter is / isn't
5
- * active. A real `<input type=checkbox>` drives it — laid INVISIBLY over the whole control, so clicking
6
- * anywhere toggles it (naturally clickable + testable + accessible + form-friendly; `bind:checked`
7
- * works, and `onchange` fires a real event). Honours the shared {@link Variant} (the "checked" colour)
8
- * + {@link Size} axes.
3
+ * Checkbox — a themed multi-state box (unchecked / checked / indeterminate). A real invisible
4
+ * `<input type=checkbox>` drives it (native click/keyboard/a11y/forms). Shared {@link Variant} +
5
+ * {@link Size} axes.
9
6
  *
10
- * ALIGNMENT: same story as Toggle — the visible box is deliberately smaller than a text control, but
11
- * the Checkbox occupies a wrapper sized to the standard control HEIGHT for its `size` (sm 24 / md 32 /
12
- * lg 40px same as Input), with the box centred inside. Drop it next to a `size`-matched Input/Select
13
- * and everything lines up on the same baseline; the full-height wrapper is the click target while the
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).
7
+ * LAYOUT: standalone, `label` follows the field-chrome rule (ABOVE the box, so mixed-control rows at
8
+ * the same `size` align). Inside a `FieldGroup`, the label sits inline to the RIGHT of the box
9
+ * (conventional list layout) and `size`/`variant`/`disabled` inherit from the group. With no chrome
10
+ * props (and no group) it renders bare + inline exactly as before (zero-regression).
19
11
  */
20
12
  -->
21
13
  <script lang="ts">
@@ -25,13 +17,14 @@
25
17
  import { responsiveClasses, type Responsive } from "../types/responsive.js";
26
18
  import { variantToken, type Variant } from "../types/variants.js";
27
19
  import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
20
+ import { getFieldGroup } from "./field-group.js";
28
21
 
29
22
  let {
30
23
  checked = $bindable(false),
31
24
  indeterminate = false,
32
25
  disabled = false,
33
- size = "md",
34
- variant = "primary",
26
+ size,
27
+ variant,
35
28
  class: cls = "",
36
29
  label = null,
37
30
  aside,
@@ -45,128 +38,106 @@
45
38
  indeterminate?: boolean;
46
39
  disabled?: boolean;
47
40
  size?: Responsive<Size>;
48
- /** The colour of the "checked" state (default primary). */
41
+ /** The colour of the "checked" state (default primary; inherits the group's variant if unset). */
49
42
  variant?: Variant;
50
43
  class?: string;
51
- /** Field chrome (see FieldChrome.spec.md). Label renders ABOVE. */
44
+ /** Field chrome. Inline (right of the box) in a FieldGroup; above when standalone. */
52
45
  label?: string | null;
53
46
  aside?: Snippet;
54
47
  error?: string | Snippet | null;
55
48
  description?: string | Snippet | null;
56
49
  } & Omit<HTMLInputAttributes, "size" | "type" | "class"> = $props();
57
50
 
58
- // Per size: visible box dimension. Same scale as icons (14 / 16 / 20 px) so the box aligns with
59
- // adjacent MDI icons at the matching size.
60
- const dims: Record<Size, string> = {
61
- sm: "size-3.5",
62
- md: "size-4",
63
- lg: "size-5",
64
- };
65
- const iconSize: Record<Size, string> = {
66
- sm: "size-3",
67
- md: "size-3.5",
68
- lg: "size-4",
69
- };
70
- // Wrapper height matches Input's h-6/h-8/h-10 so the box centres on the same baseline as sibling
71
- // controls at the same `size`.
51
+ const grp = getFieldGroup();
52
+ const inGroup = !!grp?.inGroup;
53
+ const effSize = $derived(size ?? grp?.size ?? "md");
54
+ const effVariant = $derived(variant ?? grp?.variant ?? "primary");
55
+ const effDisabled = $derived(disabled || !!grp?.disabled);
56
+
57
+ const dims: Record<Size, string> = { sm: "size-3.5", md: "size-4", lg: "size-5" };
58
+ const iconSize: Record<Size, string> = { sm: "size-3", md: "size-3.5", lg: "size-4" };
72
59
  const heightMap: Record<Size, string> = { sm: "h-6", md: "h-8", lg: "h-10" };
73
- const heightClass = $derived(responsiveClasses(size, heightMap));
74
- const boxSize = $derived(responsiveClasses(size, dims));
75
- const iconSizeCls = $derived(responsiveClasses(size, iconSize));
60
+ const textMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
61
+ const heightClass = $derived(responsiveClasses(effSize, heightMap));
62
+ const boxSize = $derived(responsiveClasses(effSize, dims));
63
+ const iconSizeCls = $derived(responsiveClasses(effSize, iconSize));
64
+ const labelText = $derived(responsiveClasses(effSize, textMap));
76
65
  const filled = $derived(checked || indeterminate);
77
- const variantVar = $derived(`var(${variantToken[variant]})`);
66
+ const variantVar = $derived(`var(${variantToken[effVariant]})`);
78
67
  const boxStyle = $derived(
79
68
  filled
80
69
  ? `background-color: ${variantVar}; border-color: ${variantVar};`
81
70
  : `background-color: transparent; border-color: color-mix(in srgb, var(--ui-color-text) 40%, transparent);`,
82
71
  );
83
- // Mark colour = the INVERTED text colour (`--ui-color-background`) with a hint of the variant mixed
84
- // in — ONE rule for every variant, both themes (matches the Toggle knob). Light: near-white on the
85
- // fill; dark: flips dark, which reads on the dark theme's brighter accent fills. The 80% background
86
- // keeps it contrasting even on a neutral (`--ui-color-text`) fill.
87
72
  const markColor = $derived(
88
- `color-mix(in srgb, var(--ui-color-background) 80%, var(${variantToken[variant]}))`,
73
+ `color-mix(in srgb, var(--ui-color-background) 80%, var(${variantToken[effVariant]}))`,
89
74
  );
90
75
 
91
- // Field chrome wiring.
92
76
  const generatedId = nextFieldId();
93
77
  const controlId = $derived(id ?? generatedId);
94
78
  const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
95
79
  const hasError = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
96
80
  const hasDesc = $derived(isSnippet(description) ? true : !!(description && String(description).trim()));
97
- const hasChrome = $derived(!!label || !!aside || hasError || hasDesc);
81
+ // Standalone chrome (label ABOVE) only when NOT in a group and some chrome prop is set.
82
+ const hasChrome = $derived(!inGroup && (!!label || !!aside || hasError || hasDesc));
83
+ // Show an inline label to the right when in a group (or standalone-bare with a label).
84
+ const showInlineLabel = $derived((inGroup || !hasChrome) && !!label);
98
85
 
99
86
  let el = $state<HTMLInputElement>();
100
- // `indeterminate` is a DOM property, not an attribute — sync it imperatively so the real
101
- // checkbox reports the tri-state to a11y and form serialization.
102
87
  $effect(() => {
103
88
  if (el) el.indeterminate = indeterminate;
104
89
  });
105
90
  </script>
106
91
 
107
- {#snippet controlMarkup(cid: string | undefined, describedBy: string | undefined)}
92
+ <!-- The box + invisible input. `wrap` = the click-target <label>; carries inline label text when set. -->
93
+ {#snippet boxControl(cid: string | undefined, describedBy: string | undefined)}
94
+ <input
95
+ bind:this={el}
96
+ type="checkbox"
97
+ bind:checked
98
+ disabled={effDisabled}
99
+ {required}
100
+ id={cid}
101
+ aria-describedby={describedBy}
102
+ aria-invalid={hasError || undefined}
103
+ class="peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
104
+ {...rest}
105
+ />
106
+ <span
107
+ 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}"
108
+ style={boxStyle}
109
+ >
110
+ {#if indeterminate}
111
+ <svg viewBox="0 0 16 16" class={iconSizeCls} style="color: {markColor}" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
112
+ <line x1="4" y1="8" x2="12" y2="8" />
113
+ </svg>
114
+ {:else if checked}
115
+ <svg viewBox="0 0 16 16" class={iconSizeCls} style="color: {markColor}" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
116
+ <polyline points="3.5,8.5 6.5,11.5 12.5,5" />
117
+ </svg>
118
+ {/if}
119
+ </span>
120
+ {/snippet}
121
+
122
+ {#snippet wrap(cid: string | undefined, describedBy: string | undefined, extra: string)}
108
123
  <label
109
- class="relative inline-flex shrink-0 items-center justify-center {heightClass} {disabled
124
+ class="relative inline-flex shrink-0 items-center {showInlineLabel
125
+ ? 'gap-2'
126
+ : 'justify-center'} {heightClass} {effDisabled
110
127
  ? 'cursor-not-allowed opacity-50'
111
- : 'cursor-pointer'} {hasChrome ? 'self-start' : cls}"
128
+ : 'cursor-pointer'} {extra}"
112
129
  >
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
146
- on accent fills, the background colour on neutral/ghost (which are text-coloured). -->
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>
130
+ {@render boxControl(cid, describedBy)}
131
+ {#if showInlineLabel}<span class="{labelText} [color:var(--ui-color-text)]">{label}</span>{/if}
161
132
  </label>
162
133
  {/snippet}
163
134
 
164
135
  {#if hasChrome}
165
- <FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} {size} class={cls}>
136
+ <FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} size={effSize} class={cls}>
166
137
  {#snippet control({ describedBy })}
167
- {@render controlMarkup(controlId, describedBy)}
138
+ {@render wrap(controlId, describedBy, "self-start")}
168
139
  {/snippet}
169
140
  </FieldChrome>
170
141
  {:else}
171
- {@render controlMarkup(undefined, undefined)}
142
+ {@render wrap(controlId, undefined, cls)}
172
143
  {/if}
@@ -8,10 +8,10 @@ type $$ComponentProps = {
8
8
  indeterminate?: boolean;
9
9
  disabled?: boolean;
10
10
  size?: Responsive<Size>;
11
- /** The colour of the "checked" state (default primary). */
11
+ /** The colour of the "checked" state (default primary; inherits the group's variant if unset). */
12
12
  variant?: Variant;
13
13
  class?: string;
14
- /** Field chrome (see FieldChrome.spec.md). Label renders ABOVE. */
14
+ /** Field chrome. Inline (right of the box) in a FieldGroup; above when standalone. */
15
15
  label?: string | null;
16
16
  aside?: Snippet;
17
17
  error?: string | Snippet | null;
@@ -0,0 +1,80 @@
1
+ <!--
2
+ /**
3
+ * FieldGroup — one wrapper for a set of Radios, Checkboxes, or Toggles under a shared legend. Renders
4
+ * FieldsetChrome (legend + description/error + orientation) and shares a group context so child
5
+ * controls render their label INLINE (right of the control) and inherit `size`/`variant`/`disabled`.
6
+ *
7
+ * For a RADIO group, bind `value` (+ optional `name`): child `<Radio>`s pick up the shared value/name
8
+ * automatically — no per-radio `bind:group`. Checkboxes/Toggles stay independent (their own
9
+ * `bind:checked`); `value`/`name` are simply ignored by them.
10
+ */
11
+ -->
12
+ <script lang="ts">
13
+ import type { Snippet } from "svelte";
14
+ import type { Size } from "../types/sizes.js";
15
+ import type { Variant } from "../types/variants.js";
16
+ import type { Responsive } from "../types/responsive.js";
17
+ import FieldsetChrome from "./FieldsetChrome.svelte";
18
+ import { setFieldGroup } from "./field-group.js";
19
+
20
+ let {
21
+ value = $bindable(),
22
+ name,
23
+ label = null,
24
+ aside,
25
+ error = null,
26
+ description = null,
27
+ required = false,
28
+ orientation = "vertical",
29
+ size = "md",
30
+ variant,
31
+ disabled = false,
32
+ class: cls = "",
33
+ children,
34
+ }: {
35
+ /** Radio groups: the selected value (bindable). Ignored by checkbox/toggle children. */
36
+ value?: string | number;
37
+ /** Radio groups: `name` applied to every child radio (native single-select + form serialization). */
38
+ name?: string;
39
+ label?: string | null;
40
+ aside?: Snippet;
41
+ error?: string | Snippet | null;
42
+ description?: string | Snippet | null;
43
+ required?: boolean;
44
+ orientation?: "vertical" | "horizontal";
45
+ size?: Responsive<Size>;
46
+ variant?: Variant;
47
+ disabled?: boolean;
48
+ class?: string;
49
+ children: Snippet;
50
+ } = $props();
51
+
52
+ // Reactive context: getters keep size/variant/orientation/disabled live; getValue/select share the
53
+ // radio-group value. Set once (context is stable); the getters read current prop values.
54
+ setFieldGroup({
55
+ inGroup: true,
56
+ get orientation() {
57
+ return orientation;
58
+ },
59
+ get size() {
60
+ return size;
61
+ },
62
+ get variant() {
63
+ return variant;
64
+ },
65
+ get disabled() {
66
+ return disabled;
67
+ },
68
+ get name() {
69
+ return name;
70
+ },
71
+ getValue: () => value,
72
+ select: (v) => {
73
+ value = v;
74
+ },
75
+ });
76
+ </script>
77
+
78
+ <FieldsetChrome {label} {aside} {error} {description} {required} {orientation} {size} class={cls}>
79
+ {@render children()}
80
+ </FieldsetChrome>
@@ -0,0 +1,24 @@
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
+ /** Radio groups: the selected value (bindable). Ignored by checkbox/toggle children. */
7
+ value?: string | number;
8
+ /** Radio groups: `name` applied to every child radio (native single-select + form serialization). */
9
+ name?: string;
10
+ label?: string | null;
11
+ aside?: Snippet;
12
+ error?: string | Snippet | null;
13
+ description?: string | Snippet | null;
14
+ required?: boolean;
15
+ orientation?: "vertical" | "horizontal";
16
+ size?: Responsive<Size>;
17
+ variant?: Variant;
18
+ disabled?: boolean;
19
+ class?: string;
20
+ children: Snippet;
21
+ };
22
+ declare const FieldGroup: import("svelte").Component<$$ComponentProps, {}, "value">;
23
+ type FieldGroup = ReturnType<typeof FieldGroup>;
24
+ export default FieldGroup;
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { Size } from "../types/sizes.js";
4
+ import { responsiveClasses, type Responsive } from "../types/responsive.js";
5
+
6
+ /**
7
+ * FieldsetChrome — the group counterpart of FieldChrome: a `<fieldset>` with a `<legend>` (the
8
+ * group label) plus the same error/description rows, and an orientation-controlled slot for its
9
+ * child controls. Used by `RadioGroup` / `FieldGroup`. Same prop vocabulary as FieldChrome.
10
+ */
11
+ interface Props {
12
+ label?: string | null;
13
+ aside?: Snippet;
14
+ error?: string | Snippet | null;
15
+ description?: string | Snippet | null;
16
+ required?: boolean;
17
+ orientation?: "vertical" | "horizontal";
18
+ size?: Responsive<Size>;
19
+ class?: string;
20
+ children: Snippet;
21
+ }
22
+
23
+ let {
24
+ label = null,
25
+ aside,
26
+ error = null,
27
+ description = null,
28
+ required = false,
29
+ orientation = "vertical",
30
+ size = "md",
31
+ class: cls = "",
32
+ children,
33
+ }: Props = $props();
34
+
35
+ const labelTextMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
36
+ const subTextMap: Record<Size, string> = { sm: "text-[0.6875rem]", md: "text-xs", lg: "text-sm" };
37
+ const labelText = $derived(responsiveClasses(size, labelTextMap));
38
+ const subText = $derived(responsiveClasses(size, subTextMap));
39
+
40
+ const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
41
+ const hasError = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
42
+ const hasDesc = $derived(isSnippet(description) ? true : !!(description && String(description).trim()));
43
+
44
+ const itemsClass = $derived(
45
+ orientation === "horizontal" ? "flex flex-wrap gap-x-6 gap-y-2" : "flex flex-col gap-2",
46
+ );
47
+ </script>
48
+
49
+ <fieldset class="m-0 flex min-w-0 flex-col gap-1 border-0 p-0 {cls}">
50
+ {#if label || aside}
51
+ <!-- legend stays a direct <fieldset> child (a11y); the row layout lives inside it -->
52
+ <legend class="flex w-full items-center justify-between gap-2 p-0">
53
+ {#if label}
54
+ <span class="{labelText} font-medium [color:var(--ui-color-text)]"
55
+ >{label}{#if required}<span aria-hidden="true" class="[color:var(--ui-color-error)]"> *</span
56
+ >{/if}</span
57
+ >
58
+ {:else}
59
+ <span></span>
60
+ {/if}
61
+ {#if aside}
62
+ <span class="{subText} flex shrink-0 items-center gap-2">{@render aside()}</span>
63
+ {/if}
64
+ </legend>
65
+ {/if}
66
+
67
+ <div class={itemsClass}>
68
+ {@render children()}
69
+ </div>
70
+
71
+ {#if hasError}
72
+ <div class="{subText} [color:var(--ui-color-error)]">
73
+ {#if isSnippet(error)}{@render error()}{:else}{@html error}{/if}
74
+ </div>
75
+ {/if}
76
+ {#if hasDesc}
77
+ <div class="{subText} [color:color-mix(in_srgb,var(--ui-color-text)_65%,transparent)]">
78
+ {#if isSnippet(description)}{@render description()}{:else}{@html description}{/if}
79
+ </div>
80
+ {/if}
81
+ </fieldset>
@@ -0,0 +1,22 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { Size } from "../types/sizes.js";
3
+ import { type Responsive } from "../types/responsive.js";
4
+ /**
5
+ * FieldsetChrome — the group counterpart of FieldChrome: a `<fieldset>` with a `<legend>` (the
6
+ * group label) plus the same error/description rows, and an orientation-controlled slot for its
7
+ * child controls. Used by `RadioGroup` / `FieldGroup`. Same prop vocabulary as FieldChrome.
8
+ */
9
+ interface Props {
10
+ label?: string | null;
11
+ aside?: Snippet;
12
+ error?: string | Snippet | null;
13
+ description?: string | Snippet | null;
14
+ required?: boolean;
15
+ orientation?: "vertical" | "horizontal";
16
+ size?: Responsive<Size>;
17
+ class?: string;
18
+ children: Snippet;
19
+ }
20
+ declare const FieldsetChrome: import("svelte").Component<Props, {}, "">;
21
+ type FieldsetChrome = ReturnType<typeof FieldsetChrome>;
22
+ export default FieldsetChrome;
@@ -0,0 +1,142 @@
1
+ <!--
2
+ /**
3
+ * Radio — a themed single-selection control (mirrors Checkbox/Toggle). A real invisible
4
+ * `<input type=radio>` drives it (native keyboard/a11y/forms). Standalone it uses `bind:group`; inside
5
+ * a `FieldGroup` it discovers the shared `name` + value via context, so no `bind:group` is needed.
6
+ *
7
+ * LABEL: inside a `FieldGroup` (the common case) the label sits inline to the RIGHT of the dot
8
+ * (conventional radio-list layout). Standalone, it follows the field-chrome rule (label ABOVE via
9
+ * FieldChrome) — or renders bare (just the dot) with no chrome props.
10
+ */
11
+ -->
12
+ <script lang="ts">
13
+ import type { HTMLInputAttributes } from "svelte/elements";
14
+ import type { Snippet } from "svelte";
15
+ import type { Size } from "../types/sizes.js";
16
+ import { responsiveClasses, type Responsive } from "../types/responsive.js";
17
+ import { variantToken, type Variant } from "../types/variants.js";
18
+ import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
19
+ import { getFieldGroup } from "./field-group.js";
20
+
21
+ let {
22
+ group = $bindable(),
23
+ value,
24
+ disabled = false,
25
+ size,
26
+ variant,
27
+ class: cls = "",
28
+ label = null,
29
+ aside,
30
+ error = null,
31
+ description = null,
32
+ id,
33
+ name,
34
+ ...rest
35
+ }: {
36
+ /** Standalone group binding (`bind:group`). Ignored inside a FieldGroup (context drives it). */
37
+ group?: string | number;
38
+ /** This radio's value — assigned to the group when picked. */
39
+ value: string | number;
40
+ disabled?: boolean;
41
+ size?: Responsive<Size>;
42
+ /** Colour of the filled dot (default primary; inherits the group's variant if unset). */
43
+ variant?: Variant;
44
+ class?: string;
45
+ /** Field chrome. Inline (right of the dot) in a FieldGroup; above when standalone. */
46
+ label?: string | null;
47
+ aside?: Snippet;
48
+ error?: string | Snippet | null;
49
+ description?: string | Snippet | null;
50
+ } & Omit<HTMLInputAttributes, "size" | "type" | "class" | "value" | "group"> = $props();
51
+
52
+ const grp = getFieldGroup();
53
+ const inGroup = !!grp?.inGroup;
54
+ const effSize = $derived(size ?? grp?.size ?? "md");
55
+ const effVariant = $derived(variant ?? grp?.variant ?? "primary");
56
+ const effName = $derived(name ?? grp?.name);
57
+ const effDisabled = $derived(disabled || !!grp?.disabled);
58
+
59
+ // Checked = matches the group value (context-driven in a FieldGroup, else the local `group` binding).
60
+ const isChecked = $derived(grp?.getValue ? grp.getValue() === value : group === value);
61
+ function selectThis() {
62
+ if (grp?.select) grp.select(value);
63
+ else group = value;
64
+ }
65
+
66
+ const dims: Record<Size, string> = { sm: "size-3.5", md: "size-4", lg: "size-5" };
67
+ const dotDims: Record<Size, string> = { sm: "size-1.5", md: "size-2", lg: "size-2.5" };
68
+ const heightMap: Record<Size, string> = { sm: "h-6", md: "h-8", lg: "h-10" };
69
+ const textMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
70
+ const heightClass = $derived(responsiveClasses(effSize, heightMap));
71
+ const boxSize = $derived(responsiveClasses(effSize, dims));
72
+ const dotSize = $derived(responsiveClasses(effSize, dotDims));
73
+ const labelText = $derived(responsiveClasses(effSize, textMap));
74
+ const variantVar = $derived(`var(${variantToken[effVariant]})`);
75
+ const ringStyle = $derived(
76
+ isChecked
77
+ ? `border-color: ${variantVar};`
78
+ : `border-color: color-mix(in srgb, var(--ui-color-text) 40%, transparent);`,
79
+ );
80
+
81
+ const generatedId = nextFieldId();
82
+ const controlId = $derived(id ?? generatedId);
83
+ const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
84
+ const hasError = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
85
+ const hasDesc = $derived(isSnippet(description) ? true : !!(description && String(description).trim()));
86
+ // Standalone chrome (label above) only when NOT in a group and some chrome prop is set.
87
+ const hasChrome = $derived(!inGroup && (!!label || !!aside || hasError || hasDesc));
88
+ </script>
89
+
90
+ <!-- The dot control (invisible radio over a ring; filled centre dot when checked). -->
91
+ {#snippet dot(cid: string | undefined, describedBy: string | undefined)}
92
+ <span
93
+ class="relative inline-flex shrink-0 items-center justify-center {heightClass} {effDisabled
94
+ ? 'cursor-not-allowed opacity-50'
95
+ : 'cursor-pointer'}"
96
+ >
97
+ <input
98
+ type="radio"
99
+ {value}
100
+ name={effName}
101
+ checked={isChecked}
102
+ disabled={effDisabled}
103
+ id={cid}
104
+ aria-describedby={describedBy}
105
+ onchange={selectThis}
106
+ class="peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
107
+ {...rest}
108
+ />
109
+ <span
110
+ class="relative inline-flex items-center justify-center rounded-full 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}"
111
+ style={ringStyle}
112
+ >
113
+ {#if isChecked}
114
+ <span class="rounded-full {dotSize}" style="background-color: {variantVar}"></span>
115
+ {/if}
116
+ </span>
117
+ </span>
118
+ {/snippet}
119
+
120
+ <!-- Inline layout (in a group): dot + label to the right. -->
121
+ {#snippet inlineControl(cid: string | undefined, describedBy: string | undefined)}
122
+ <label
123
+ class="inline-flex items-center gap-2 {effDisabled
124
+ ? 'cursor-not-allowed opacity-50'
125
+ : 'cursor-pointer'} {cls}"
126
+ >
127
+ {@render dot(cid, describedBy)}
128
+ {#if label}<span class="{labelText} [color:var(--ui-color-text)]">{label}</span>{/if}
129
+ </label>
130
+ {/snippet}
131
+
132
+ {#if inGroup}
133
+ {@render inlineControl(controlId, undefined)}
134
+ {:else if hasChrome}
135
+ <FieldChrome {label} {aside} {error} {description} controlId={controlId} size={effSize} class={cls}>
136
+ {#snippet control({ describedBy })}
137
+ {@render dot(controlId, describedBy)}
138
+ {/snippet}
139
+ </FieldChrome>
140
+ {:else}
141
+ {@render inlineControl(controlId, undefined)}
142
+ {/if}
@@ -0,0 +1,24 @@
1
+ import type { HTMLInputAttributes } from "svelte/elements";
2
+ import type { Snippet } from "svelte";
3
+ import type { Size } from "../types/sizes.js";
4
+ import { type Responsive } from "../types/responsive.js";
5
+ import { type Variant } from "../types/variants.js";
6
+ type $$ComponentProps = {
7
+ /** Standalone group binding (`bind:group`). Ignored inside a FieldGroup (context drives it). */
8
+ group?: string | number;
9
+ /** This radio's value — assigned to the group when picked. */
10
+ value: string | number;
11
+ disabled?: boolean;
12
+ size?: Responsive<Size>;
13
+ /** Colour of the filled dot (default primary; inherits the group's variant if unset). */
14
+ variant?: Variant;
15
+ class?: string;
16
+ /** Field chrome. Inline (right of the dot) in a FieldGroup; above when standalone. */
17
+ label?: string | null;
18
+ aside?: Snippet;
19
+ error?: string | Snippet | null;
20
+ description?: string | Snippet | null;
21
+ } & Omit<HTMLInputAttributes, "size" | "type" | "class" | "value" | "group">;
22
+ declare const Radio: import("svelte").Component<$$ComponentProps, {}, "group">;
23
+ type Radio = ReturnType<typeof Radio>;
24
+ export default Radio;
@@ -1,20 +1,11 @@
1
1
  <!--
2
2
  /**
3
- * Toggle — a sliding on/off switch la a physical switch). Use this, NOT a Checkbox, for binary
4
- * state: enabled/disabled, active/inactive, on/off. A real `<input type=checkbox>` drives it — laid
5
- * INVISIBLY over the whole control, so clicking anywhere toggles it (naturally clickable +
6
- * testable + accessible + form-friendly; `bind:checked` works, and `onchange` fires a real event).
7
- * Honours the shared {@link Variant} (the "on" colour) + {@link Size} axes. Self-contained: no external
8
- * label needed — pass an `aria-label` when there's no adjacent text.
3
+ * Toggle — a sliding on/off switch. A real invisible `<input type=checkbox>` drives it (native
4
+ * click/keyboard/a11y/forms). Shared {@link Variant} + {@link Size} axes.
9
5
  *
10
- * ALIGNMENT: the visible switch is deliberately shorter than a text control, but the Toggle occupies a
11
- * wrapper sized to the standard control HEIGHT for its `size` (sm 24 / md 32 / lg 40px same as Input),
12
- * with the switch centred inside. So a Toggle dropped next to `size`-matched Inputs/Selects lines up on
13
- * the same baseline (and the full-height wrapper is the click target), while the switch itself stays
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
6
+ * LAYOUT: standalone, `label` follows the field-chrome rule (ABOVE the switch, so mixed-control rows
7
+ * at the same `size` align). Inside a `FieldGroup`, the label sits inline to the RIGHT of the switch
8
+ * and `size`/`variant`/`disabled` inherit from the group. With no chrome props (and no group) it
18
9
  * renders bare + inline exactly as before (zero-regression).
19
10
  */
20
11
  -->
@@ -25,12 +16,13 @@
25
16
  import { responsiveClasses, resolveScalar, type Responsive } from "../types/responsive.js";
26
17
  import { variantToken, type Variant } from "../types/variants.js";
27
18
  import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
19
+ import { getFieldGroup } from "./field-group.js";
28
20
 
29
21
  let {
30
22
  checked = $bindable(false),
31
23
  disabled = false,
32
- size = "md",
33
- variant = "primary",
24
+ size,
25
+ variant,
34
26
  class: cls = "",
35
27
  label = null,
36
28
  aside,
@@ -43,18 +35,22 @@
43
35
  checked?: boolean;
44
36
  disabled?: boolean;
45
37
  size?: Responsive<Size>;
46
- /** The colour of the "on" state (default primary). */
38
+ /** The colour of the "on" state (default primary; inherits the group's variant if unset). */
47
39
  variant?: Variant;
48
40
  class?: string;
49
- /** Field chrome (see FieldChrome.spec.md). Label renders ABOVE. */
41
+ /** Field chrome. Inline (right of the switch) in a FieldGroup; above when standalone. */
50
42
  label?: string | null;
51
43
  aside?: Snippet;
52
44
  error?: string | Snippet | null;
53
45
  description?: string | Snippet | null;
54
46
  } & Omit<HTMLInputAttributes, "size" | "type" | "class"> = $props();
55
47
 
56
- // Per size: track w/h + knob size are CLASSES (responsive); knob travel is an inline-style transform
57
- // (JS value → keys off the base tier). Knob sits 2px in, travels W − K − 4px.
48
+ const grp = getFieldGroup();
49
+ const inGroup = !!grp?.inGroup;
50
+ const effSize = $derived(size ?? grp?.size ?? "md");
51
+ const effVariant = $derived(variant ?? grp?.variant ?? "primary");
52
+ const effDisabled = $derived(disabled || !!grp?.disabled);
53
+
58
54
  const trackMap: Record<Size, string> = { sm: "h-4 w-7", md: "h-5 w-9", lg: "h-6 w-11" };
59
55
  const knobMap: Record<Size, string> = { sm: "size-3", md: "size-4", lg: "size-5" };
60
56
  const onMap: Record<Size, string> = {
@@ -63,72 +59,70 @@
63
59
  lg: "translateX(20px)",
64
60
  };
65
61
  const heightMap: Record<Size, string> = { sm: "h-6", md: "h-8", lg: "h-10" };
62
+ const textMap: Record<Size, string> = { sm: "text-xs", md: "text-sm", lg: "text-base" };
66
63
 
67
- const trackCls = $derived(responsiveClasses(size, trackMap));
68
- const knobCls = $derived(responsiveClasses(size, knobMap));
69
- // Wrapper height = the standard control height for this size (matches Input's h-6/h-8/h-10), so the
70
- // switch centres on the same baseline as sibling inputs without growing the switch itself.
71
- const heightClass = $derived(responsiveClasses(size, heightMap));
64
+ const trackCls = $derived(responsiveClasses(effSize, trackMap));
65
+ const knobCls = $derived(responsiveClasses(effSize, knobMap));
66
+ const heightClass = $derived(responsiveClasses(effSize, heightMap));
67
+ const labelText = $derived(responsiveClasses(effSize, textMap));
72
68
  const trackStyle = $derived(
73
- `background-color: ${checked ? `var(${variantToken[variant]})` : "color-mix(in srgb, var(--ui-color-text) 22%, transparent)"}`,
69
+ `background-color: ${checked ? `var(${variantToken[effVariant]})` : "color-mix(in srgb, var(--ui-color-text) 22%, transparent)"}`,
74
70
  );
75
- // Knob colour = the INVERTED text colour (`--ui-color-background`) with a hint of the variant mixed
76
- // in — ONE rule for every variant, both themes. Light: background is white → a near-white knob (as
77
- // before). Dark: background is dark → the knob flips dark, and since the dark theme's accent fills
78
- // are brighter, a dark knob still reads. The 20% variant tint keeps the knob on-theme; it never
79
- // approaches the track colour (80% background), so it stays contrasting even on a neutral track.
80
71
  const knobColor = $derived(
81
- `color-mix(in srgb, var(--ui-color-background) 80%, var(${variantToken[variant]}))`,
72
+ `color-mix(in srgb, var(--ui-color-background) 80%, var(${variantToken[effVariant]}))`,
82
73
  );
83
74
  const knobStyle = $derived(
84
- `transform: ${checked ? onMap[resolveScalar(size, "md")] : "translateX(2px)"}; background-color: ${knobColor}`,
75
+ `transform: ${checked ? onMap[resolveScalar(effSize, "md")] : "translateX(2px)"}; background-color: ${knobColor}`,
85
76
  );
86
77
 
87
- // Field chrome wiring.
88
78
  const generatedId = nextFieldId();
89
79
  const controlId = $derived(id ?? generatedId);
90
80
  const isSnippet = (v: unknown): v is Snippet => typeof v === "function";
91
81
  const hasError = $derived(isSnippet(error) ? true : !!(error && String(error).trim()));
92
82
  const hasDesc = $derived(isSnippet(description) ? true : !!(description && String(description).trim()));
93
- const hasChrome = $derived(!!label || !!aside || hasError || hasDesc);
83
+ const hasChrome = $derived(!inGroup && (!!label || !!aside || hasError || hasDesc));
84
+ const showInlineLabel = $derived((inGroup || !hasChrome) && !!label);
94
85
  </script>
95
86
 
96
- {#snippet controlMarkup(cid: string | undefined, describedBy: string | undefined)}
87
+ {#snippet switchControl(cid: string | undefined, describedBy: string | undefined)}
88
+ <input
89
+ type="checkbox"
90
+ bind:checked
91
+ disabled={effDisabled}
92
+ {required}
93
+ id={cid}
94
+ aria-describedby={describedBy}
95
+ aria-invalid={hasError || undefined}
96
+ class="peer absolute inset-0 z-10 m-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
97
+ {...rest}
98
+ />
99
+ <span
100
+ 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}"
101
+ style={trackStyle}
102
+ >
103
+ <span class="inline-block rounded-full shadow transition-transform duration-150 {knobCls}" style={knobStyle}></span>
104
+ </span>
105
+ {/snippet}
106
+
107
+ {#snippet wrap(cid: string | undefined, describedBy: string | undefined, extra: string)}
97
108
  <label
98
- class="relative inline-flex shrink-0 items-center {heightClass} {disabled
109
+ class="relative inline-flex shrink-0 items-center {showInlineLabel
110
+ ? 'gap-2'
111
+ : ''} {heightClass} {effDisabled
99
112
  ? 'cursor-not-allowed opacity-50'
100
- : 'cursor-pointer'} {hasChrome ? 'self-start' : cls}"
113
+ : 'cursor-pointer'} {extra}"
101
114
  >
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
- />
114
- <span
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>
115
+ {@render switchControl(cid, describedBy)}
116
+ {#if showInlineLabel}<span class="{labelText} [color:var(--ui-color-text)]">{label}</span>{/if}
123
117
  </label>
124
118
  {/snippet}
125
119
 
126
120
  {#if hasChrome}
127
- <FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} {size} class={cls}>
121
+ <FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} size={effSize} class={cls}>
128
122
  {#snippet control({ describedBy })}
129
- {@render controlMarkup(controlId, describedBy)}
123
+ {@render wrap(controlId, describedBy, "self-start")}
130
124
  {/snippet}
131
125
  </FieldChrome>
132
126
  {:else}
133
- {@render controlMarkup(undefined, undefined)}
127
+ {@render wrap(controlId, undefined, cls)}
134
128
  {/if}
@@ -7,10 +7,10 @@ type $$ComponentProps = {
7
7
  checked?: boolean;
8
8
  disabled?: boolean;
9
9
  size?: Responsive<Size>;
10
- /** The colour of the "on" state (default primary). */
10
+ /** The colour of the "on" state (default primary; inherits the group's variant if unset). */
11
11
  variant?: Variant;
12
12
  class?: string;
13
- /** Field chrome (see FieldChrome.spec.md). Label renders ABOVE. */
13
+ /** Field chrome. Inline (right of the switch) in a FieldGroup; above when standalone. */
14
14
  label?: string | null;
15
15
  aside?: Snippet;
16
16
  error?: string | Snippet | null;
@@ -0,0 +1,17 @@
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 FieldGroupContext {
5
+ /** Present ⇒ the item is inside a group and renders its label inline (right of the control). */
6
+ inGroup: true;
7
+ orientation: "vertical" | "horizontal";
8
+ size?: Responsive<Size>;
9
+ variant?: Variant;
10
+ disabled?: boolean;
11
+ /** RadioGroup only — the shared radio-group name + controlled value. */
12
+ name?: string;
13
+ getValue?: () => string | number | undefined;
14
+ select?: (v: string | number) => void;
15
+ }
16
+ export declare function setFieldGroup(ctx: FieldGroupContext): void;
17
+ export declare function getFieldGroup(): FieldGroupContext | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared context for grouped fields (`RadioGroup`, `FieldGroup`). A grouped item (Radio, Checkbox,
3
+ * Toggle) reads this to (a) switch its `label` from the standalone "above" layout to the conventional
4
+ * dot/box + label-to-the-right, and (b) inherit the group's `size`/`variant`. `RadioGroup` also shares
5
+ * the radio-group value + `name` so a child `<Radio>` needs no `bind:group`.
6
+ */
7
+ import { getContext, setContext } from "svelte";
8
+ const KEY = Symbol("svelte-ui-field-group");
9
+ export function setFieldGroup(ctx) {
10
+ setContext(KEY, ctx);
11
+ }
12
+ export function getFieldGroup() {
13
+ return getContext(KEY);
14
+ }
package/dist/index.d.ts CHANGED
@@ -33,6 +33,8 @@ export { default as Popup } from "./components/Popup.svelte";
33
33
  export { default as Menu } from "./components/Menu.svelte";
34
34
  export { default as Checkbox } from "./components/Checkbox.svelte";
35
35
  export { default as Toggle } from "./components/Toggle.svelte";
36
+ export { default as Radio } from "./components/Radio.svelte";
37
+ export { default as FieldGroup } from "./components/FieldGroup.svelte";
36
38
  export { default as SideBarMenu } from "./components/SideBarMenu/SideBarMenu.svelte";
37
39
  export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, } from "./components/SideBarMenu/SideBarMenu.svelte";
38
40
  export { default as TabStrip } from "./components/TabStrip/TabStrip.svelte";
package/dist/index.js CHANGED
@@ -38,6 +38,8 @@ export { default as Popup } from "./components/Popup.svelte";
38
38
  export { default as Menu } from "./components/Menu.svelte";
39
39
  export { default as Checkbox } from "./components/Checkbox.svelte";
40
40
  export { default as Toggle } from "./components/Toggle.svelte";
41
+ export { default as Radio } from "./components/Radio.svelte";
42
+ export { default as FieldGroup } from "./components/FieldGroup.svelte";
41
43
  // Navigation Components
42
44
  export { default as SideBarMenu } from "./components/SideBarMenu/SideBarMenu.svelte";
43
45
  export { default as TabStrip } from "./components/TabStrip/TabStrip.svelte";
@@ -99,12 +99,40 @@ Multi-line text input with optional auto-resize.
99
99
 
100
100
  ### Checkbox
101
101
 
102
- Small themed checkbox (native control tinted via `accent-color`).
102
+ Small themed checkbox (invisible native control over a themed box; multi-state).
103
103
 
104
104
  - **Location**: `src/lib/components/Checkbox.svelte`
105
- - **Axes**: `size`
105
+ - **Axes**: `size`, `variant` + field chrome (see "Field chrome (shared)")
106
106
  - **Props**: `checked` (bindable), `indeterminate`, `disabled`, plus native input attributes
107
107
 
108
+ ### Toggle
109
+
110
+ Sliding on/off switch (invisible native checkbox over a themed track/knob).
111
+
112
+ - **Location**: `src/lib/components/Toggle.svelte`
113
+ - **Axes**: `size`, `variant` + field chrome
114
+ - **Props**: `checked` (bindable), `disabled`, plus native input attributes
115
+
116
+ ### Radio
117
+
118
+ Themed single-select control mirroring Checkbox/Toggle (invisible `<input type=radio>`).
119
+
120
+ - **Location**: `src/lib/components/Radio.svelte`
121
+ - **Axes**: `size`, `variant` + field chrome
122
+ - **Props**: `value` (this radio's value), `group` (bindable — standalone `bind:group`), `label`,
123
+ `disabled`, `name`. Inside a `FieldGroup` the label renders inline (right of the dot) and the shared
124
+ `name`/value come from group context (no `bind:group` needed).
125
+
126
+ ### FieldGroup
127
+
128
+ One wrapper for a set of Radios, Checkboxes, or Toggles under a shared legend (a `<fieldset>`).
129
+
130
+ - **Location**: `src/lib/components/FieldGroup.svelte`
131
+ - **Props**: `label` (legend), `aside`, `description`, `error`, `required` (the shared field-chrome
132
+ vocabulary), `orientation` (`'vertical'` default | `'horizontal'`), `size`, `variant`, `disabled`
133
+ (propagate to children), and for radio groups `value` (bindable) + `name`. Children render their
134
+ label inline; radios auto-share the group value/name via context.
135
+
108
136
  ### Rating
109
137
 
110
138
  Read-only star-rating display (5-star, from a 10-point average).
package/docs/usage.md CHANGED
@@ -116,6 +116,31 @@ Every field-shaped component renders its own label + help/error frame — don't
116
116
  `validate` (on `Input`/`TextArea`) is separate — it shows the **native** validity tooltip on submit;
117
117
  the `error` prop is the inline row. Both flip the error border.
118
118
 
119
+ ### Radio & groups
120
+
121
+ `FieldGroup` wraps a set of Radios, Checkboxes, or Toggles under one legend; items render their label
122
+ inline. For radios, bind the group `value` — children need no `bind:group`.
123
+
124
+ ```svelte
125
+ <script>
126
+ import { FieldGroup, Radio, Checkbox } from "@spaethtech/svelte-ui";
127
+ let method = $state("email");
128
+ let caps = $state({ webhook: true, script: false });
129
+ </script>
130
+
131
+ <FieldGroup label="Delivery method" bind:value={method} name="method"
132
+ description="How you'll get updates.">
133
+ <Radio value="email" label="Email" />
134
+ <Radio value="sms" label="SMS" />
135
+ <Radio value="push" label="Push notification" />
136
+ </FieldGroup>
137
+
138
+ <FieldGroup label="Capabilities" orientation="horizontal">
139
+ <Checkbox label="Webhook triggers" bind:checked={caps.webhook} />
140
+ <Checkbox label="Script actions" bind:checked={caps.script} />
141
+ </FieldGroup>
142
+ ```
143
+
119
144
  ### Button
120
145
 
121
146
  ```svelte
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.4.1-dev.21.2ccc703",
3
+ "version": "0.4.1-dev.23.372ce0b",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"