@spaethtech/svelte-ui 0.4.1-dev.22.6370a63 → 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.
- package/.claude/skills/svelte-ui/SKILL.md +14 -1
- package/dist/components/Checkbox.svelte +71 -100
- package/dist/components/Checkbox.svelte.d.ts +2 -2
- package/dist/components/FieldGroup.svelte +80 -0
- package/dist/components/FieldGroup.svelte.d.ts +24 -0
- package/dist/components/FieldsetChrome.svelte +81 -0
- package/dist/components/FieldsetChrome.svelte.d.ts +22 -0
- package/dist/components/Radio.svelte +142 -0
- package/dist/components/Radio.svelte.d.ts +24 -0
- package/dist/components/Toggle.svelte +56 -62
- package/dist/components/Toggle.svelte.d.ts +2 -2
- package/dist/components/field-group.d.ts +17 -0
- package/dist/components/field-group.js +14 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/docs/components.md +30 -2
- package/docs/usage.md +25 -0
- package/package.json +1 -1
|
@@ -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` `
|
|
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`**
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
/**
|
|
3
|
-
* Checkbox — a themed multi-state box (unchecked / checked / indeterminate).
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
* and
|
|
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
|
|
34
|
-
variant
|
|
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 (
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
74
|
-
const
|
|
75
|
-
const
|
|
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[
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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'} {
|
|
128
|
+
: 'cursor-pointer'} {extra}"
|
|
112
129
|
>
|
|
113
|
-
|
|
114
|
-
|
|
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} {
|
|
136
|
+
<FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} size={effSize} class={cls}>
|
|
166
137
|
{#snippet control({ describedBy })}
|
|
167
|
-
{@render
|
|
138
|
+
{@render wrap(controlId, describedBy, "self-start")}
|
|
168
139
|
{/snippet}
|
|
169
140
|
</FieldChrome>
|
|
170
141
|
{:else}
|
|
171
|
-
{@render
|
|
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 (
|
|
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
|
|
4
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
33
|
-
variant
|
|
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 (
|
|
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
|
-
|
|
57
|
-
|
|
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(
|
|
68
|
-
const knobCls = $derived(responsiveClasses(
|
|
69
|
-
|
|
70
|
-
|
|
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[
|
|
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[
|
|
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(
|
|
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
|
|
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 {
|
|
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'} {
|
|
113
|
+
: 'cursor-pointer'} {extra}"
|
|
101
114
|
>
|
|
102
|
-
|
|
103
|
-
<
|
|
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} {
|
|
121
|
+
<FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} size={effSize} class={cls}>
|
|
128
122
|
{#snippet control({ describedBy })}
|
|
129
|
-
{@render
|
|
123
|
+
{@render wrap(controlId, describedBy, "self-start")}
|
|
130
124
|
{/snippet}
|
|
131
125
|
</FieldChrome>
|
|
132
126
|
{:else}
|
|
133
|
-
{@render
|
|
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 (
|
|
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";
|
package/docs/components.md
CHANGED
|
@@ -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
|
|
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
|