@spaethtech/svelte-ui 0.10.0 → 0.11.0
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 +9 -3
- package/dist/components/ButtonGroup/ButtonGroup.svelte +236 -0
- package/dist/components/ButtonGroup/ButtonGroup.svelte.d.ts +42 -0
- package/dist/components/ButtonGroup/index.d.ts +2 -0
- package/dist/components/ButtonGroup/index.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/docs/components.md +21 -0
- package/docs/usage.md +79 -0
- package/package.json +1 -1
|
@@ -153,7 +153,13 @@ All from `@spaethtech/svelte-ui` (see the shipped `docs/components.md` + `docs/u
|
|
|
153
153
|
examples):
|
|
154
154
|
|
|
155
155
|
- **Form:** `Button` `ButtonDropdown` `Input` `Select` `List` `TextArea` `Checkbox` `Toggle` `Radio`
|
|
156
|
-
`Rating` · **`FieldGroup`** (fieldset wrapper for radio/checkbox/toggle sets)
|
|
156
|
+
`Rating` · **`FieldGroup`** (fieldset wrapper for radio/checkbox/toggle sets) · **`ButtonGroup`**
|
|
157
|
+
(joined row/`col` of `<Button>`s as one unit — an action toolbar or a **controlled** segmented
|
|
158
|
+
selector; `items` of `ButtonGroupItem` {`value`,`text`,`icon`,`variant`,`disabled`,`menu`,`onclick`}
|
|
159
|
+
or a presentational default slot; `select` `'none'|'single'|'multi'` with `bind:value`/`bind:values`;
|
|
160
|
+
selected = variant-swap to filled, unselected = `ghost`; the group owns the frame; an item `menu`
|
|
161
|
+
(`MenuItem[]`) makes a split-button. Not a form field — for legend/`name`/submit use `FieldGroup`;
|
|
162
|
+
for view switching use `TabStrip`.)
|
|
157
163
|
- **Specialized inputs:** `PasswordInput` `EmailInput` `SearchInput` `NumberInput` (formatting +
|
|
158
164
|
`percent`/`stepper`/`clamp`/`liveFormat`) `PhoneInput` (stores E.164; dep-free, inject
|
|
159
165
|
`parse`/`format` for per-country)
|
|
@@ -180,8 +186,8 @@ examples):
|
|
|
180
186
|
`bind:value`; children are `<Disclosure value="…">`, no per-item `bind:open`)
|
|
181
187
|
- **Display / theme:** `NotesEditor` · `ThemeSelector` (Auto/Light/Dark Select) · `ThemeToggle`
|
|
182
188
|
(compact ☀/☾ icon button, same persisted state)
|
|
183
|
-
- **Types / utils:** `Variant` `Size` `TabDefinition`
|
|
184
|
-
`BREAKPOINT_PX`.
|
|
189
|
+
- **Types / utils:** `Variant` `Size` `TabDefinition` `MenuItem` `ButtonGroupItem` `ButtonGroupSelect`
|
|
190
|
+
(types), `responsiveClasses` / `resolveScalar`, `BREAKPOINT_PX`.
|
|
185
191
|
|
|
186
192
|
## Adopting updates
|
|
187
193
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
/**
|
|
3
|
+
* ButtonGroup — a joined row/column of buttons rendered as one unit: an action toolbar, or a
|
|
4
|
+
* CONTROLLED segmented single/multi selector. Composes `<Button>` (never a raw `<button>`); the
|
|
5
|
+
* group owns the frame (border + radius + internal seams) so ends round automatically.
|
|
6
|
+
*
|
|
7
|
+
* Two authoring modes:
|
|
8
|
+
* - `items` (data-driven, default): the managed path — selection, the selected variant-swap, ARIA,
|
|
9
|
+
* and keyboard live here. Required for `select` modes.
|
|
10
|
+
* - default slot: a presentational escape hatch — hand-authored `<Button>`s get the frame; YOU own
|
|
11
|
+
* their behaviour (selection is `items`-only).
|
|
12
|
+
*
|
|
13
|
+
* See `ButtonGroup.spec.md` for the full contract.
|
|
14
|
+
*/
|
|
15
|
+
-->
|
|
16
|
+
<script lang="ts" module>
|
|
17
|
+
import type { Snippet } from "svelte";
|
|
18
|
+
import type { Variant } from "../../types/variants.js";
|
|
19
|
+
import type { MenuItem } from "../../data/table/types.js";
|
|
20
|
+
|
|
21
|
+
export type ButtonGroupSelect = "none" | "single" | "multi";
|
|
22
|
+
|
|
23
|
+
/** A managed item — rendered internally as a `<Button>`; fields mirror the relevant Button props. */
|
|
24
|
+
export type ButtonGroupItem = {
|
|
25
|
+
/** Identity for `select` modes. */
|
|
26
|
+
value?: string;
|
|
27
|
+
text?: string;
|
|
28
|
+
/** Icon slot — a consumer-rendered component (e.g. `~icons/mdi/*`), like `Button`'s `icon`. */
|
|
29
|
+
icon?: Snippet;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Per-item colour override; otherwise the group `variant`. */
|
|
32
|
+
variant?: Variant;
|
|
33
|
+
/** Split-button dropdown: a trailing caret opens a `Menu` with these items. */
|
|
34
|
+
menu?: MenuItem[];
|
|
35
|
+
/** Action-mode click. Fires in `select="none"`; in selection modes it runs after the select. */
|
|
36
|
+
onclick?: (e: MouseEvent) => void;
|
|
37
|
+
};
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<script lang="ts">
|
|
41
|
+
import Button from "../Button.svelte";
|
|
42
|
+
import Menu from "../Menu.svelte";
|
|
43
|
+
import type { Size } from "../../types/sizes.js";
|
|
44
|
+
import type { Responsive } from "../../types/responsive.js";
|
|
45
|
+
import IconChevronDown from "~icons/mdi/chevron-down";
|
|
46
|
+
import { DEV } from "esm-env";
|
|
47
|
+
|
|
48
|
+
let {
|
|
49
|
+
items,
|
|
50
|
+
select = "none",
|
|
51
|
+
value = $bindable(),
|
|
52
|
+
values = $bindable([]),
|
|
53
|
+
orientation = "row",
|
|
54
|
+
variant = "primary",
|
|
55
|
+
size = "md",
|
|
56
|
+
disabled = false,
|
|
57
|
+
class: cls = "",
|
|
58
|
+
children,
|
|
59
|
+
}: {
|
|
60
|
+
/** Managed items. Omit to use the default slot instead. */
|
|
61
|
+
items?: ButtonGroupItem[];
|
|
62
|
+
/** Selection behaviour (items mode only; the slot is always presentational). */
|
|
63
|
+
select?: ButtonGroupSelect;
|
|
64
|
+
/** Selected value in `select="single"` (bindable). */
|
|
65
|
+
value?: string;
|
|
66
|
+
/** Selected values in `select="multi"` (bindable). */
|
|
67
|
+
values?: string[];
|
|
68
|
+
orientation?: "row" | "col";
|
|
69
|
+
/** Group accent — the selected item fills with it; per-item `variant` overrides. */
|
|
70
|
+
variant?: Variant;
|
|
71
|
+
size?: Responsive<Size>;
|
|
72
|
+
/** Disable the whole group (per-item `disabled` also honoured). */
|
|
73
|
+
disabled?: boolean;
|
|
74
|
+
class?: string;
|
|
75
|
+
children?: Snippet;
|
|
76
|
+
} = $props();
|
|
77
|
+
|
|
78
|
+
if (DEV && items && children) {
|
|
79
|
+
console.warn("ButtonGroup: both `items` and children provided — using `items`, ignoring children.");
|
|
80
|
+
}
|
|
81
|
+
if (DEV && items && select !== "none") {
|
|
82
|
+
for (const it of items) {
|
|
83
|
+
if (it.value == null) {
|
|
84
|
+
console.warn(`ButtonGroup: item "${it.text ?? "?"}" has no \`value\` — it can never be selected in select="${select}".`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const isRow = $derived(orientation === "row");
|
|
90
|
+
|
|
91
|
+
// ── Selection ───────────────────────────────────────────────────
|
|
92
|
+
const isSelected = (v?: string): boolean => {
|
|
93
|
+
if (v == null || select === "none") return false;
|
|
94
|
+
return select === "single" ? value === v : values.includes(v);
|
|
95
|
+
};
|
|
96
|
+
function activate(item: ButtonGroupItem, e: MouseEvent) {
|
|
97
|
+
if (disabled || item.disabled) return;
|
|
98
|
+
const v = item.value;
|
|
99
|
+
if (v != null) {
|
|
100
|
+
if (select === "single") value = v;
|
|
101
|
+
else if (select === "multi") values = values.includes(v) ? values.filter((x) => x !== v) : [...values, v];
|
|
102
|
+
}
|
|
103
|
+
item.onclick?.(e);
|
|
104
|
+
}
|
|
105
|
+
// Selected → filled (group/item variant); unselected in a select mode → ghost. `none` → always filled.
|
|
106
|
+
const variantFor = (item: ButtonGroupItem): Variant => {
|
|
107
|
+
const base = item.variant ?? variant;
|
|
108
|
+
return select === "none" || isSelected(item.value) ? base : "ghost";
|
|
109
|
+
};
|
|
110
|
+
// Per-item ARIA for the selection semantics.
|
|
111
|
+
const selectAttrs = (item: ButtonGroupItem) =>
|
|
112
|
+
select === "single"
|
|
113
|
+
? { role: "radio", "aria-checked": isSelected(item.value) }
|
|
114
|
+
: select === "multi"
|
|
115
|
+
? { "aria-pressed": isSelected(item.value) }
|
|
116
|
+
: {};
|
|
117
|
+
|
|
118
|
+
const groupRole = $derived(
|
|
119
|
+
select === "single" ? "radiogroup" : select === "multi" ? "group" : "toolbar",
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// ── Split-button menus ──────────────────────────────────────────
|
|
123
|
+
// Per-item open flag (bound to each `Menu`, which closes itself on select / outside-click).
|
|
124
|
+
// Seed to `false` synchronously (before first render) so `bind:open` never sees `undefined` —
|
|
125
|
+
// binding `undefined` to Menu's `open` (which has a fallback) is a fatal error.
|
|
126
|
+
let menuOpen = $state<Record<number, boolean>>({});
|
|
127
|
+
for (let i = 0; i < (items?.length ?? 0); i++) menuOpen[i] = false;
|
|
128
|
+
let caretRefs = $state<Record<number, HTMLButtonElement | HTMLAnchorElement | undefined>>({});
|
|
129
|
+
|
|
130
|
+
// ── Roving focus / arrow-keys (single mode = radiogroup) ────────
|
|
131
|
+
let mainRefs = $state<Record<number, HTMLButtonElement | HTMLAnchorElement | undefined>>({});
|
|
132
|
+
const enabledIdx = $derived(
|
|
133
|
+
(items ?? []).map((it, i) => (disabled || it.disabled ? -1 : i)).filter((i) => i >= 0),
|
|
134
|
+
);
|
|
135
|
+
function onKeydown(e: KeyboardEvent) {
|
|
136
|
+
if (select !== "single" || !items) return;
|
|
137
|
+
const fwd = isRow ? "ArrowRight" : "ArrowDown";
|
|
138
|
+
const back = isRow ? "ArrowLeft" : "ArrowUp";
|
|
139
|
+
if (e.key !== fwd && e.key !== back) return;
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
if (enabledIdx.length === 0) return;
|
|
142
|
+
const cur = items.findIndex((it) => it.value != null && it.value === value);
|
|
143
|
+
const pos = enabledIdx.indexOf(cur);
|
|
144
|
+
const nextPos =
|
|
145
|
+
e.key === fwd
|
|
146
|
+
? (pos + 1 + enabledIdx.length) % enabledIdx.length
|
|
147
|
+
: (pos - 1 + enabledIdx.length) % enabledIdx.length;
|
|
148
|
+
const nextIdx = enabledIdx[pos === -1 ? 0 : nextPos];
|
|
149
|
+
const item = items[nextIdx];
|
|
150
|
+
if (item?.value != null) {
|
|
151
|
+
value = item.value;
|
|
152
|
+
mainRefs[nextIdx]?.focus();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Roving tabindex: only the selected (or first enabled) item is tab-focusable in single mode.
|
|
156
|
+
const tabIdxFor = (i: number, item: ButtonGroupItem): 0 | -1 => {
|
|
157
|
+
if (select !== "single") return 0;
|
|
158
|
+
const selectedIdx = items?.findIndex((it) => it.value != null && it.value === value) ?? -1;
|
|
159
|
+
const anchor = selectedIdx >= 0 ? selectedIdx : (enabledIdx[0] ?? 0);
|
|
160
|
+
return i === anchor ? 0 : -1;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Frame: the group owns border + radius + seams; inner buttons are squared via a descendant
|
|
164
|
+
// selector (`[&_button]`), which beats Button's own radius by specificity (reliable).
|
|
165
|
+
const frameClass = $derived(
|
|
166
|
+
[
|
|
167
|
+
"inline-flex",
|
|
168
|
+
isRow ? "flex-row" : "flex-col",
|
|
169
|
+
isRow ? "divide-x" : "divide-y",
|
|
170
|
+
"divide-[var(--ui-border-color)]",
|
|
171
|
+
"overflow-hidden border [border-color:var(--ui-border-color)] [border-radius:var(--ui-border-radius)]",
|
|
172
|
+
"[&_button]:rounded-none [&_a]:rounded-none",
|
|
173
|
+
cls,
|
|
174
|
+
].join(" "),
|
|
175
|
+
);
|
|
176
|
+
</script>
|
|
177
|
+
|
|
178
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
179
|
+
<div role={groupRole} class={frameClass} onkeydown={onKeydown}>
|
|
180
|
+
{#if items}
|
|
181
|
+
{#each items as item, i (item.value ?? item.text ?? i)}
|
|
182
|
+
{#if item.menu}
|
|
183
|
+
<!-- Split button: main (action/select) + caret (opens the Menu). One cell in the group. -->
|
|
184
|
+
<span class="inline-flex {isRow ? '[&>button+button]:border-l' : '[&>button+button]:border-t'} [&>button+button]:[border-color:var(--ui-border-color)]">
|
|
185
|
+
<Button
|
|
186
|
+
icon={item.icon}
|
|
187
|
+
text={item.text}
|
|
188
|
+
variant={variantFor(item)}
|
|
189
|
+
{size}
|
|
190
|
+
disabled={disabled || item.disabled}
|
|
191
|
+
bind:element={mainRefs[i]}
|
|
192
|
+
tabindex={tabIdxFor(i, item)}
|
|
193
|
+
{...selectAttrs(item)}
|
|
194
|
+
onclick={(e) => activate(item, e)}
|
|
195
|
+
/>
|
|
196
|
+
<Button
|
|
197
|
+
variant={variantFor(item)}
|
|
198
|
+
{size}
|
|
199
|
+
disabled={disabled || item.disabled}
|
|
200
|
+
aria-haspopup="menu"
|
|
201
|
+
aria-expanded={!!menuOpen[i]}
|
|
202
|
+
aria-label="More"
|
|
203
|
+
bind:element={caretRefs[i]}
|
|
204
|
+
onclick={() => (menuOpen[i] = !menuOpen[i])}
|
|
205
|
+
>
|
|
206
|
+
{#snippet icon()}<IconChevronDown />{/snippet}
|
|
207
|
+
</Button>
|
|
208
|
+
</span>
|
|
209
|
+
{:else}
|
|
210
|
+
<Button
|
|
211
|
+
icon={item.icon}
|
|
212
|
+
text={item.text}
|
|
213
|
+
variant={variantFor(item)}
|
|
214
|
+
{size}
|
|
215
|
+
disabled={disabled || item.disabled}
|
|
216
|
+
bind:element={mainRefs[i]}
|
|
217
|
+
tabindex={tabIdxFor(i, item)}
|
|
218
|
+
{...selectAttrs(item)}
|
|
219
|
+
onclick={(e) => activate(item, e)}
|
|
220
|
+
/>
|
|
221
|
+
{/if}
|
|
222
|
+
{/each}
|
|
223
|
+
{:else}
|
|
224
|
+
{@render children?.()}
|
|
225
|
+
{/if}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<!-- Split-button dropdowns — top-layer, anchored to each caret. Kept OUTSIDE the frame so the frame's
|
|
229
|
+
`overflow-hidden` never clips them and they don't count as extra `divide-x` cells. -->
|
|
230
|
+
{#if items}
|
|
231
|
+
{#each items as item, i (item.value ?? item.text ?? i)}
|
|
232
|
+
{#if item.menu}
|
|
233
|
+
<Menu anchor={caretRefs[i]} bind:open={menuOpen[i]} items={item.menu} {size} />
|
|
234
|
+
{/if}
|
|
235
|
+
{/each}
|
|
236
|
+
{/if}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { Variant } from "../../types/variants.js";
|
|
3
|
+
import type { MenuItem } from "../../data/table/types.js";
|
|
4
|
+
export type ButtonGroupSelect = "none" | "single" | "multi";
|
|
5
|
+
/** A managed item — rendered internally as a `<Button>`; fields mirror the relevant Button props. */
|
|
6
|
+
export type ButtonGroupItem = {
|
|
7
|
+
/** Identity for `select` modes. */
|
|
8
|
+
value?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
/** Icon slot — a consumer-rendered component (e.g. `~icons/mdi/*`), like `Button`'s `icon`. */
|
|
11
|
+
icon?: Snippet;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** Per-item colour override; otherwise the group `variant`. */
|
|
14
|
+
variant?: Variant;
|
|
15
|
+
/** Split-button dropdown: a trailing caret opens a `Menu` with these items. */
|
|
16
|
+
menu?: MenuItem[];
|
|
17
|
+
/** Action-mode click. Fires in `select="none"`; in selection modes it runs after the select. */
|
|
18
|
+
onclick?: (e: MouseEvent) => void;
|
|
19
|
+
};
|
|
20
|
+
import type { Size } from "../../types/sizes.js";
|
|
21
|
+
import type { Responsive } from "../../types/responsive.js";
|
|
22
|
+
type $$ComponentProps = {
|
|
23
|
+
/** Managed items. Omit to use the default slot instead. */
|
|
24
|
+
items?: ButtonGroupItem[];
|
|
25
|
+
/** Selection behaviour (items mode only; the slot is always presentational). */
|
|
26
|
+
select?: ButtonGroupSelect;
|
|
27
|
+
/** Selected value in `select="single"` (bindable). */
|
|
28
|
+
value?: string;
|
|
29
|
+
/** Selected values in `select="multi"` (bindable). */
|
|
30
|
+
values?: string[];
|
|
31
|
+
orientation?: "row" | "col";
|
|
32
|
+
/** Group accent — the selected item fills with it; per-item `variant` overrides. */
|
|
33
|
+
variant?: Variant;
|
|
34
|
+
size?: Responsive<Size>;
|
|
35
|
+
/** Disable the whole group (per-item `disabled` also honoured). */
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
class?: string;
|
|
38
|
+
children?: Snippet;
|
|
39
|
+
};
|
|
40
|
+
declare const ButtonGroup: import("svelte").Component<$$ComponentProps, {}, "value" | "values">;
|
|
41
|
+
type ButtonGroup = ReturnType<typeof ButtonGroup>;
|
|
42
|
+
export default ButtonGroup;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ButtonGroup } from "./ButtonGroup.svelte";
|
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,9 @@ export { default as ThemeSelector } from "./components/ThemeSelector.svelte";
|
|
|
32
32
|
export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
|
|
33
33
|
export { default as Popup } from "./components/Popup.svelte";
|
|
34
34
|
export { default as Menu } from "./components/Menu.svelte";
|
|
35
|
+
export type { MenuItem } from "./data/table/types.js";
|
|
36
|
+
export { ButtonGroup } from "./components/ButtonGroup/index.js";
|
|
37
|
+
export type { ButtonGroupItem, ButtonGroupSelect } from "./components/ButtonGroup/index.js";
|
|
35
38
|
export { default as Checkbox } from "./components/Checkbox.svelte";
|
|
36
39
|
export { default as Toggle } from "./components/Toggle.svelte";
|
|
37
40
|
export { default as Radio } from "./components/Radio.svelte";
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ export { default as ThemeToggle } from "./components/ThemeToggle.svelte";
|
|
|
37
37
|
// Utility Components
|
|
38
38
|
export { default as Popup } from "./components/Popup.svelte";
|
|
39
39
|
export { default as Menu } from "./components/Menu.svelte";
|
|
40
|
+
export { ButtonGroup } from "./components/ButtonGroup/index.js";
|
|
40
41
|
export { default as Checkbox } from "./components/Checkbox.svelte";
|
|
41
42
|
export { default as Toggle } from "./components/Toggle.svelte";
|
|
42
43
|
export { default as Radio } from "./components/Radio.svelte";
|
package/docs/components.md
CHANGED
|
@@ -135,6 +135,27 @@ One wrapper for a set of Radios, Checkboxes, or Toggles under a shared legend (a
|
|
|
135
135
|
`variant`, `disabled` (propagate to children), and for radio groups `value` (bindable) + `name`.
|
|
136
136
|
Children render their label inline; radios auto-share the group value/name via context.
|
|
137
137
|
|
|
138
|
+
### ButtonGroup
|
|
139
|
+
|
|
140
|
+
A **joined** row (or column) of buttons rendered as one visual unit — an action toolbar and/or a
|
|
141
|
+
**controlled** segmented single/multi selector. Composes `<Button>` (never a raw `<button>`); the
|
|
142
|
+
**group owns the frame** (border, radius, internal `divide-*` seams) so the outer ends round and the
|
|
143
|
+
middles sit flush. The selected/"down" state is a **variant-swap** — unselected buttons are `ghost`,
|
|
144
|
+
the selected one fills with the group `variant`. Not a form field (controlled via `bind:value`, no
|
|
145
|
+
`name`/submit — for those use `FieldGroup`); not navigation (for view switching use `TabStrip`).
|
|
146
|
+
|
|
147
|
+
- **Location**: `src/lib/components/ButtonGroup/ButtonGroup.svelte`
|
|
148
|
+
- **Axes**: `variant` (the selected fill; per-item `variant` overrides), `size`
|
|
149
|
+
- **Props**: `items` (`ButtonGroupItem[]` — the managed, recommended path), `select`
|
|
150
|
+
(`'none' | 'single' | 'multi'`, default `'none'`), `value` (bindable, single), `values` (bindable
|
|
151
|
+
`string[]`, multi), `orientation` (`'row' | 'col'`, default `'row'`), `disabled` (whole group),
|
|
152
|
+
`class`, `children` (a **presentational** default slot — hand-authored `<Button>`s get the frame but
|
|
153
|
+
you own their behaviour; mutually exclusive with `items`, which wins + DEV-warns).
|
|
154
|
+
- **`ButtonGroupItem`**: `{ value?, text?, icon? (Snippet), disabled?, variant?, menu? (MenuItem[] —
|
|
155
|
+
renders a split-button caret opening a `Menu`), onclick? }`.
|
|
156
|
+
- **Selection / a11y**: `'single'` → `role="radiogroup"` + `aria-checked`, arrow-key roving;
|
|
157
|
+
`'multi'` → `aria-pressed`; `'none'` → `role="toolbar"`, `item.onclick` fires (no swap).
|
|
158
|
+
|
|
138
159
|
### Grid
|
|
139
160
|
|
|
140
161
|
Minimal auto-placed CSS-grid layout primitive — every direct child flows into an equal cell (no
|
package/docs/usage.md
CHANGED
|
@@ -220,6 +220,85 @@ A `Disclosure` is one expand/collapse section (`bind:open`); an `Accordion` coor
|
|
|
220
220
|
</Button>
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
+
### ButtonGroup
|
|
224
|
+
|
|
225
|
+
A joined row/column of buttons — an action toolbar or a controlled segmented selector. Composes
|
|
226
|
+
`<Button>`; the group owns the frame and the selected state is a variant-swap (`ghost` → filled).
|
|
227
|
+
|
|
228
|
+
```svelte
|
|
229
|
+
<script>
|
|
230
|
+
import { ButtonGroup, Button, toast } from "@spaethtech/svelte-ui";
|
|
231
|
+
import IconAlignLeft from "~icons/mdi/format-align-left";
|
|
232
|
+
import IconAlignCenter from "~icons/mdi/format-align-center";
|
|
233
|
+
import IconAlignRight from "~icons/mdi/format-align-right";
|
|
234
|
+
import IconContentSave from "~icons/mdi/content-save";
|
|
235
|
+
import IconExport from "~icons/mdi/export-variant";
|
|
236
|
+
import IconList from "~icons/mdi/view-sequential";
|
|
237
|
+
import IconGrid from "~icons/mdi/view-grid";
|
|
238
|
+
|
|
239
|
+
// Single-select (segmented): exactly one value, bound.
|
|
240
|
+
let align = $state("left");
|
|
241
|
+
const alignItems = [
|
|
242
|
+
{ value: "left", text: "Left", icon: iLeft },
|
|
243
|
+
{ value: "center", text: "Center", icon: iCenter },
|
|
244
|
+
{ value: "right", text: "Right", icon: iRight },
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
// Multi-select: a set of values, bound.
|
|
248
|
+
let marks = $state(["bold"]);
|
|
249
|
+
|
|
250
|
+
// Action toolbar (select="none"): each item runs its onclick; `menu` → split-button.
|
|
251
|
+
const actions = [
|
|
252
|
+
{ text: "Save", icon: iSave, onclick: () => toast.success("Saved") },
|
|
253
|
+
{
|
|
254
|
+
text: "Export",
|
|
255
|
+
icon: iExport,
|
|
256
|
+
onclick: () => toast.success("Exported"),
|
|
257
|
+
menu: [
|
|
258
|
+
{ label: "Export as CSV", onclick: () => toast.success("CSV") },
|
|
259
|
+
{ label: "Export as JSON", onclick: () => toast.success("JSON") },
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
// Slot mode wires its own selection.
|
|
265
|
+
let view = $state("list");
|
|
266
|
+
</script>
|
|
267
|
+
|
|
268
|
+
{#snippet iLeft()}<IconAlignLeft />{/snippet}
|
|
269
|
+
{#snippet iCenter()}<IconAlignCenter />{/snippet}
|
|
270
|
+
{#snippet iRight()}<IconAlignRight />{/snippet}
|
|
271
|
+
{#snippet iSave()}<IconContentSave />{/snippet}
|
|
272
|
+
{#snippet iExport()}<IconExport />{/snippet}
|
|
273
|
+
|
|
274
|
+
<!-- Segmented single-select (arrow keys move the selection) -->
|
|
275
|
+
<ButtonGroup items={alignItems} select="single" bind:value={align} variant="primary" />
|
|
276
|
+
|
|
277
|
+
<!-- Multi-select toggle -->
|
|
278
|
+
<ButtonGroup select="multi" bind:values={marks} variant="secondary"
|
|
279
|
+
items={[
|
|
280
|
+
{ value: "bold", text: "Bold" },
|
|
281
|
+
{ value: "italic", text: "Italic" },
|
|
282
|
+
{ value: "underline", text: "Underline" },
|
|
283
|
+
]} />
|
|
284
|
+
|
|
285
|
+
<!-- Action toolbar with a split-button (Export has a `menu`) -->
|
|
286
|
+
<ButtonGroup items={actions} variant="secondary" />
|
|
287
|
+
|
|
288
|
+
<!-- Vertical -->
|
|
289
|
+
<ButtonGroup items={alignItems} select="single" bind:value={align} orientation="col" />
|
|
290
|
+
|
|
291
|
+
<!-- Slot (presentational): hand-authored Buttons get the frame; you own behaviour + selected look -->
|
|
292
|
+
<ButtonGroup>
|
|
293
|
+
<Button text="List" variant={view === "list" ? "primary" : "ghost"} onclick={() => (view = "list")}>
|
|
294
|
+
{#snippet icon()}<IconList />{/snippet}
|
|
295
|
+
</Button>
|
|
296
|
+
<Button text="Grid" variant={view === "grid" ? "primary" : "ghost"} onclick={() => (view = "grid")}>
|
|
297
|
+
{#snippet icon()}<IconGrid />{/snippet}
|
|
298
|
+
</Button>
|
|
299
|
+
</ButtonGroup>
|
|
300
|
+
```
|
|
301
|
+
|
|
223
302
|
### Input
|
|
224
303
|
|
|
225
304
|
```svelte
|