@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.
@@ -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` (types), `responsiveClasses` / `resolveScalar`,
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,2 @@
1
+ export { default as ButtonGroup } from "./ButtonGroup.svelte";
2
+ export type { ButtonGroupItem, ButtonGroupSelect } from "./ButtonGroup.svelte";
@@ -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";
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"