@marianmeres/stuic 3.148.0 → 3.149.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.
@@ -115,7 +115,7 @@ if (isTooltipSupported()) {
115
115
  - Requires CSS Anchor Positioning support (no fallback)
116
116
  - Shows on hover or focus with 200ms delay
117
117
  - Tooltip persists when hovering over it
118
- - ARIA attributes are automatically managed (`aria-describedby`, `aria-expanded`)
118
+ - ARIA attributes are automatically managed (`aria-describedby`)
119
119
  - Maximum width is 16rem (256px) by default
120
120
 
121
121
  ## CSS Variables
@@ -157,8 +157,11 @@ export function tooltip(anchorEl, fn) {
157
157
  // node once init
158
158
  // Use addAnchorName to support multiple anchor names on same element (e.g., popover + tooltip)
159
159
  addAnchorName(anchorEl, anchorName);
160
+ // NOTE: aria-describedby is the only ARIA wiring a tooltip anchor should get
161
+ // (per the WAI-ARIA tooltip pattern). Do NOT manage aria-expanded here — it
162
+ // belongs to disclosure widgets (menu/combobox triggers), and writing it would
163
+ // clobber legitimate values set by consumers (e.g. DropdownMenu trigger props).
160
164
  anchorEl.setAttribute("aria-describedby", id);
161
- anchorEl.setAttribute("aria-expanded", "false");
162
165
  const debug = (...args) => {
163
166
  if (do_debug)
164
167
  console.debug("[tooltip]", rnd, ...args);
@@ -222,7 +225,6 @@ export function tooltip(anchorEl, fn) {
222
225
  if (!hide_timer) {
223
226
  debug("show...");
224
227
  clear_show();
225
- anchorEl.setAttribute("aria-expanded", "true");
226
228
  //
227
229
  tooltipEl.classList.add("tt-block");
228
230
  // Backstop: the CSS @position-try fallbacks handle most edge cases,
@@ -245,7 +247,6 @@ export function tooltip(anchorEl, fn) {
245
247
  hide_timer = setTimeout(() => {
246
248
  debug("hide...");
247
249
  clear_hide();
248
- anchorEl.setAttribute("aria-expanded", "false");
249
250
  tooltipEl.classList.remove("tt-visible");
250
251
  setTimeout(() => {
251
252
  tooltipEl.classList.remove("tt-block");
@@ -0,0 +1,163 @@
1
+ # SplitButton
2
+
3
+ A primary action button fused with an adjacent secondary trigger on a shared pill
4
+ surface — the pattern seen in meeting toolbars (mic / camera controls) and classic
5
+ "Save ▾" split buttons. The secondary trigger opens a built-in [DropdownMenu](../DropdownMenu/README.md)
6
+ (`items`) or fires a custom callback (`onSecondaryClick`). Both segments support
7
+ tooltips out of the box.
8
+
9
+ Two visual modes:
10
+
11
+ - **Raised** (default) — a distinct primary button sitting on a shared pill surface,
12
+ with a neutral ghost icon trigger beside it (the "meeting toolbar" look).
13
+ - **Divided** (`divided`) — classic split button: both segments flush on one surface,
14
+ separated by a hairline divider, secondary inheriting the primary intent/variant.
15
+
16
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ | ------------------- | ----------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------- |
20
+ | `children` | `Snippet<[{ checked? }]>` | - | Primary button content (label and/or custom markup) |
21
+ | `icon` | `string \| Snippet` | - | Convenience primary icon (SVG string or snippet), rendered before `children`. Icon-only → implies icon button |
22
+ | `onclick` | `(e: MouseEvent) => void` | - | Primary click handler |
23
+ | `intent` | `IntentColorKey` | - | Color intent forwarded to the primary button |
24
+ | `variant` | `ButtonVariant` | `"solid"` | Visual variant forwarded to the primary button |
25
+ | `size` | `ButtonSize` | `"md"` | Size preset forwarded to both segments |
26
+ | `iconButton` | `boolean` | auto | Icon-only primary. Auto-enabled when `icon` is set without `children` |
27
+ | `checked` | `boolean` | `false` | Toggle state forwarded to the primary button (bindable, pairs with `iconSwap`) |
28
+ | `roleSwitch` | `boolean` | `false` | Switch/toggle behavior on the primary button |
29
+ | `iconSwap` | `[icon, icon]` | - | Two icon states for the primary swap animation |
30
+ | `disabled` | `boolean` | - | Disable the primary button only |
31
+ | `tooltip` | `string \| TooltipConfig` | - | Tooltip for the primary button |
32
+ | `primaryProps` | `Partial<ButtonProps>` | - | Escape hatch — any extra `ButtonProps` for the primary (wins over conveniences) |
33
+ | `classPrimary` | `string` | - | Classes for the primary button |
34
+ | `primaryEl` | `HTMLElement` | - | Primary element reference (bindable) |
35
+ | `placement` | `"start" \| "end"` | `"start"` | Which side the secondary trigger sits on (logical, RTL-aware) |
36
+ | `secondaryIcon` | `string \| Snippet` | chevron | Secondary trigger icon. The default chevron rotates while the menu is open |
37
+ | `secondaryLabel` | `string` | `"More options"` | Accessible label for the (icon-only) secondary trigger |
38
+ | `secondaryTooltip` | `string \| TooltipConfig` | - | Tooltip for the secondary trigger |
39
+ | `secondaryDisabled` | `boolean` | - | Disable the secondary trigger only |
40
+ | `onSecondaryClick` | `(e: MouseEvent) => void` | - | Custom secondary handler — used instead of the dropdown. Ignored when `items` set |
41
+ | `secondaryProps` | `Partial<ButtonProps>` | - | Escape hatch — any extra `ButtonProps` for the secondary trigger |
42
+ | `classSecondary` | `string` | - | Classes for the secondary trigger |
43
+ | `secondaryEl` | `HTMLElement` | - | Secondary element reference (bindable) |
44
+ | `items` | `DropdownMenuItem[]` | - | Menu items — when provided, the secondary trigger opens a `DropdownMenu` |
45
+ | `menuOpen` | `boolean` | `false` | Menu open state (bindable) |
46
+ | `position` | `DropdownMenuPosition` | `"bottom-span-right"` | Menu position relative to the secondary trigger |
47
+ | `offset` | `string` | `"0.25rem"` | Menu offset from the secondary trigger (CSS value) |
48
+ | `menuProps` | `Partial<DropdownMenuProps>`¹ | - | Escape hatch — any extra `DropdownMenuProps` (wins over `position`/`offset`) |
49
+ | `divided` | `boolean` | `false` | Classic split-button look: flush segments + hairline divider |
50
+ | `roundedFull` | `boolean` | `true` | Fully-rounded pill. Set `false` to use the button radius |
51
+ | `unstyled` | `boolean` | `false` | Skip all default styling |
52
+ | `class` | `string` | - | Additional CSS classes for the wrapper |
53
+ | `el` | `HTMLDivElement` | - | Wrapper element reference (bindable) |
54
+
55
+ ¹ `menuProps` excludes the keys the component reserves for its own wiring:
56
+ `items`, `trigger`, `isOpen`, `triggerEl`, `class`.
57
+
58
+ Remaining props are spread onto the wrapper `<div role="group">` (e.g. `aria-label`, `data-testid`).
59
+
60
+ ## Usage
61
+
62
+ ### Meeting-toolbar pill (raised, icon swap, menu)
63
+
64
+ ```svelte
65
+ <script lang="ts">
66
+ import { SplitButton, type DropdownMenuItem } from "@marianmeres/stuic";
67
+ import { iconLucideMic } from "@marianmeres/icons-fns/lucide/iconLucideMic.js";
68
+ import { iconLucideMicOff } from "@marianmeres/icons-fns/lucide/iconLucideMicOff.js";
69
+ import { iconLucideEllipsisVertical } from "@marianmeres/icons-fns/lucide/iconLucideEllipsisVertical.js";
70
+
71
+ let muted = $state(false);
72
+ const items: DropdownMenuItem[] = [
73
+ { type: "action", id: "a", label: "Audio settings", onSelect: () => {} },
74
+ ];
75
+ </script>
76
+
77
+ <SplitButton
78
+ bind:checked={muted}
79
+ roleSwitch
80
+ iconSwap={[iconLucideMic({ size: 24 }), iconLucideMicOff({ size: 24 })]}
81
+ intent={muted ? "destructive" : undefined}
82
+ tooltip={muted ? "Turn on microphone" : "Turn off microphone"}
83
+ secondaryIcon={iconLucideEllipsisVertical({ size: 20 })}
84
+ secondaryLabel="Audio options"
85
+ secondaryTooltip="Audio options"
86
+ position="top-span-right"
87
+ {items}
88
+ primaryProps={{ "aria-label": "Toggle microphone" }}
89
+ />
90
+ ```
91
+
92
+ ### Classic split button (divided, label, chevron at end)
93
+
94
+ ```svelte
95
+ <SplitButton
96
+ divided
97
+ placement="end"
98
+ intent="primary"
99
+ roundedFull={false}
100
+ onclick={() => save()}
101
+ {items}
102
+ tooltip="Save"
103
+ secondaryTooltip="More save options"
104
+ >
105
+ Save
106
+ </SplitButton>
107
+ ```
108
+
109
+ ### Callback instead of the built-in dropdown
110
+
111
+ ```svelte
112
+ <script lang="ts">
113
+ import { SplitButton, iconSettings } from "@marianmeres/stuic";
114
+ </script>
115
+
116
+ <SplitButton icon={iconSettings()} onSecondaryClick={() => openCustomPanel()} />
117
+ ```
118
+
119
+ When `items` is provided, `onSecondaryClick` is ignored — the built-in dropdown wins.
120
+
121
+ ## Accessibility
122
+
123
+ - Wrapper is a `role="group"` — pass `aria-label` to name the group.
124
+ - Two tab stops (primary, secondary). The secondary trigger carries
125
+ `aria-haspopup="menu"` / `aria-expanded` / `aria-controls` wiring only when the
126
+ built-in dropdown is active.
127
+ - The secondary trigger is icon-only; `secondaryLabel` provides its accessible
128
+ name (and doubles as tooltip fallback content).
129
+ - Menu keyboard handling (arrows, Escape, focus return) comes from `DropdownMenu`.
130
+
131
+ ## State attributes
132
+
133
+ | Attribute | On | Meaning |
134
+ | ------------------- | ------- | ------------------------------------------ |
135
+ | `data-placement` | wrapper | `start` / `end` |
136
+ | `data-divided` | wrapper | Divided (classic) mode |
137
+ | `data-rounded-full` | wrapper | Fully-rounded pill |
138
+ | `data-size` | wrapper | Size preset |
139
+ | `data-open` | wrapper | Menu currently open (also when `unstyled`) |
140
+
141
+ ## CSS Variables
142
+
143
+ ```css
144
+ :root {
145
+ /* Raised (default) mode: shared pill surface behind both segments */
146
+ --stuic-split-button-bg: var(--stuic-color-surface);
147
+ --stuic-split-button-padding: calc(var(--spacing) * 1);
148
+ --stuic-split-button-gap: calc(var(--spacing) * 0.5);
149
+
150
+ /* Divider (divided mode only) */
151
+ --stuic-split-button-divider-bg: var(--stuic-color-border);
152
+ --stuic-split-button-divider-width: 1px;
153
+ --stuic-split-button-divider-inset: calc(var(--spacing) * 1.5);
154
+ }
155
+
156
+ /* structural (resolved at element level with global fallbacks) */
157
+ /* --stuic-split-button-radius: var(--stuic-radius-button) */
158
+ /* --stuic-split-button-transition: var(--stuic-transition) */
159
+ ```
160
+
161
+ In `divided` mode with bordered variants (`outline`), the segment borders plus the
162
+ divider can read as a thicker seam — set `--stuic-split-button-divider-width: 0`
163
+ to rely on the borders alone.
@@ -0,0 +1,294 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { Snippet } from "svelte";
4
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
5
+ import type {
6
+ Props as ButtonProps,
7
+ ButtonVariant,
8
+ ButtonSize,
9
+ } from "../Button/Button.svelte";
10
+ import type {
11
+ Props as DropdownMenuProps,
12
+ DropdownMenuItem,
13
+ DropdownMenuPosition,
14
+ } from "../DropdownMenu/DropdownMenu.svelte";
15
+ import type { TooltipConfig } from "../../actions/tooltip/tooltip.svelte.js";
16
+
17
+ /** Which side of the primary button the secondary (menu) trigger sits on. Logical (RTL-aware). */
18
+ export type SplitButtonPlacement = "start" | "end";
19
+
20
+ /** Args passed by DropdownMenu's `trigger` snippet (internal). */
21
+ interface SecondaryTriggerArgs {
22
+ isOpen: boolean;
23
+ toggle: () => void;
24
+ triggerProps: {
25
+ id: string;
26
+ "aria-haspopup": "menu";
27
+ "aria-expanded": boolean;
28
+ "aria-controls": string;
29
+ };
30
+ }
31
+
32
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
33
+ // ----- primary segment -----
34
+ /** Primary button content (label and/or custom markup). */
35
+ children?: Snippet<[{ checked?: boolean }]>;
36
+ /**
37
+ * Convenience primary icon (SVG string or snippet). Rendered before `children`.
38
+ * When provided without `children`, the primary implicitly becomes an icon button.
39
+ */
40
+ icon?: string | Snippet;
41
+ /** Primary click handler. */
42
+ onclick?: (e: MouseEvent) => void;
43
+ /** Color intent forwarded to the primary button. */
44
+ intent?: IntentColorKey;
45
+ /** Visual variant forwarded to the primary button. */
46
+ variant?: ButtonVariant | string;
47
+ /** Size preset forwarded to both segments. */
48
+ size?: ButtonSize | string;
49
+ /** Icon-only primary (forwarded to Button). Auto-enabled when `icon` is set without `children`. */
50
+ iconButton?: boolean;
51
+ /** Toggle state forwarded to the primary button (pairs with `iconSwap`/`roleSwitch`). Bindable. */
52
+ checked?: boolean;
53
+ /** Enable switch/toggle behavior on the primary button. */
54
+ roleSwitch?: boolean;
55
+ /** Two icon states for swap animation on the primary button (implies icon button). */
56
+ iconSwap?: ButtonProps["iconSwap"];
57
+ /** Disable the primary button only (see `secondaryDisabled` for the trigger). */
58
+ disabled?: boolean;
59
+ /** Tooltip for the primary button (content string or config). */
60
+ tooltip?: string | TooltipConfig;
61
+ /**
62
+ * Escape hatch: any extra `ButtonProps` forwarded to the primary button.
63
+ * Wins over the top-level conveniences above.
64
+ */
65
+ primaryProps?: Partial<ButtonProps>;
66
+ /** Classes for the primary button. */
67
+ classPrimary?: string;
68
+ /** Bindable primary button element reference. */
69
+ primaryEl?: HTMLElement;
70
+
71
+ // ----- secondary (menu trigger) segment -----
72
+ /** Which side the secondary trigger sits on (default `"start"` — the raised/pill look). */
73
+ placement?: SplitButtonPlacement;
74
+ /** Secondary trigger icon (SVG string or snippet). Defaults to a chevron that rotates when open. */
75
+ secondaryIcon?: string | Snippet;
76
+ /** Accessible label for the secondary trigger (default `"More options"`). */
77
+ secondaryLabel?: string;
78
+ /** Tooltip for the secondary trigger (content string or config). */
79
+ secondaryTooltip?: string | TooltipConfig;
80
+ /** Disable the secondary trigger only. */
81
+ secondaryDisabled?: boolean;
82
+ /**
83
+ * Custom secondary click handler — used INSTEAD of the built-in dropdown.
84
+ * Only relevant when `items` is not provided (`items` takes precedence).
85
+ */
86
+ onSecondaryClick?: (e: MouseEvent) => void;
87
+ /** Escape hatch: any extra `ButtonProps` forwarded to the secondary trigger button. */
88
+ secondaryProps?: Partial<ButtonProps>;
89
+ /** Classes for the secondary trigger button. */
90
+ classSecondary?: string;
91
+ /** Bindable secondary trigger element reference. */
92
+ secondaryEl?: HTMLElement;
93
+
94
+ // ----- dropdown menu -----
95
+ /** Menu items — when provided, the secondary trigger opens a `DropdownMenu`. */
96
+ items?: DropdownMenuItem[];
97
+ /** Bindable menu open state. */
98
+ menuOpen?: boolean;
99
+ /** Menu position relative to the secondary trigger. */
100
+ position?: DropdownMenuPosition;
101
+ /** Menu offset from the secondary trigger (CSS value). */
102
+ offset?: string;
103
+ /**
104
+ * Escape hatch: any extra `DropdownMenuProps` forwarded to the `DropdownMenu`.
105
+ * Wins over the top-level `position`/`offset` conveniences.
106
+ */
107
+ menuProps?: Partial<
108
+ Omit<DropdownMenuProps, "items" | "trigger" | "isOpen" | "triggerEl" | "class">
109
+ >;
110
+
111
+ // ----- surface -----
112
+ /**
113
+ * Classic split-button look: flush segments + hairline divider — instead of the
114
+ * default "raised" look (a distinct primary button on a shared pill surface).
115
+ */
116
+ divided?: boolean;
117
+ /** Fully-rounded pill (default `true`). Set `false` to use the button radius. */
118
+ roundedFull?: boolean;
119
+
120
+ // ----- universal -----
121
+ /** Skip all default styling, use only custom classes */
122
+ unstyled?: boolean;
123
+ /** Additional CSS classes for the wrapper. */
124
+ class?: string;
125
+ /** Bindable wrapper element reference. */
126
+ el?: HTMLDivElement;
127
+ }
128
+ </script>
129
+
130
+ <script lang="ts">
131
+ import { twMerge } from "../../utils/tw-merge.js";
132
+ import Button from "../Button/Button.svelte";
133
+ import DropdownMenu from "../DropdownMenu/DropdownMenu.svelte";
134
+ import { iconChevronDown } from "../../icons/index.js";
135
+
136
+ let {
137
+ children,
138
+ icon,
139
+ onclick,
140
+ intent,
141
+ variant = "solid",
142
+ size = "md",
143
+ iconButton,
144
+ checked = $bindable(false),
145
+ roleSwitch = false,
146
+ iconSwap,
147
+ disabled,
148
+ tooltip,
149
+ primaryProps,
150
+ classPrimary,
151
+ primaryEl = $bindable(),
152
+ placement = "start",
153
+ secondaryIcon,
154
+ secondaryLabel = "More options",
155
+ secondaryTooltip,
156
+ secondaryDisabled,
157
+ onSecondaryClick,
158
+ secondaryProps,
159
+ classSecondary,
160
+ secondaryEl = $bindable(),
161
+ items,
162
+ menuOpen = $bindable(false),
163
+ position,
164
+ offset,
165
+ menuProps,
166
+ divided = false,
167
+ roundedFull = true,
168
+ unstyled = false,
169
+ class: classProp,
170
+ el = $bindable(),
171
+ ...rest
172
+ }: Props = $props();
173
+
174
+ let _class = $derived(unstyled ? classProp : twMerge("stuic-split-button", classProp));
175
+
176
+ // icon-only primary → icon button (explicit `iconButton` always wins)
177
+ let _iconButton = $derived(iconButton ?? (!!icon && !children));
178
+
179
+ // Secondary defaults are mode-aware: neutral ghost circle on the raised pill
180
+ // surface, primary-matching flush segment in divided mode.
181
+ let _secondaryVariant = $derived(divided ? variant : "ghost");
182
+ let _secondaryIntent = $derived(divided ? intent : undefined);
183
+
184
+ // In divided mode segment corners are managed by CSS (flush inner, rounded outer)
185
+ let _segmentRoundedFull = $derived(!divided && roundedFull);
186
+ </script>
187
+
188
+ {#snippet primaryContent({ checked }: { checked?: boolean })}
189
+ {#if typeof icon === "string"}
190
+ <span class="stuic-split-button-icon" aria-hidden="true">{@html icon}</span>
191
+ {:else if icon}
192
+ {@render icon()}
193
+ {/if}
194
+ {@render children?.({ checked })}
195
+ {/snippet}
196
+
197
+ {#snippet primary()}
198
+ <Button
199
+ bind:el={primaryEl}
200
+ bind:checked
201
+ class={twMerge("stuic-split-button-primary", classPrimary)}
202
+ {intent}
203
+ {variant}
204
+ {size}
205
+ iconButton={_iconButton}
206
+ {roleSwitch}
207
+ {iconSwap}
208
+ {disabled}
209
+ {tooltip}
210
+ {unstyled}
211
+ roundedFull={_segmentRoundedFull}
212
+ type="button"
213
+ {onclick}
214
+ children={icon || children ? primaryContent : undefined}
215
+ {...primaryProps}
216
+ />
217
+ {/snippet}
218
+
219
+ {#snippet secondary(args?: SecondaryTriggerArgs)}
220
+ <Button
221
+ bind:el={secondaryEl}
222
+ class={twMerge("stuic-split-button-secondary", classSecondary)}
223
+ intent={_secondaryIntent}
224
+ variant={_secondaryVariant}
225
+ {size}
226
+ iconButton
227
+ {unstyled}
228
+ roundedFull={_segmentRoundedFull}
229
+ disabled={secondaryDisabled}
230
+ tooltip={secondaryTooltip}
231
+ aria-label={secondaryLabel}
232
+ type="button"
233
+ onclick={(e: MouseEvent) => (args ? args.toggle() : onSecondaryClick?.(e))}
234
+ {...args?.triggerProps}
235
+ {...secondaryProps}
236
+ >
237
+ {#if typeof secondaryIcon === "string"}
238
+ <span class="stuic-split-button-icon" aria-hidden="true">{@html secondaryIcon}</span
239
+ >
240
+ {:else if secondaryIcon}
241
+ {@render secondaryIcon()}
242
+ {:else}
243
+ <span class="stuic-split-button-caret" aria-hidden="true">
244
+ {@html iconChevronDown({ size: 20 })}
245
+ </span>
246
+ {/if}
247
+ </Button>
248
+ {/snippet}
249
+
250
+ {#snippet dropdownTrigger(args: SecondaryTriggerArgs)}
251
+ {@render secondary(args)}
252
+ {/snippet}
253
+
254
+ {#snippet secondaryRegion()}
255
+ {#if items}
256
+ <DropdownMenu
257
+ {items}
258
+ bind:isOpen={menuOpen}
259
+ bind:triggerEl={
260
+ () => secondaryEl as HTMLButtonElement | undefined, (v) => (secondaryEl = v)
261
+ }
262
+ {position}
263
+ {offset}
264
+ {unstyled}
265
+ class="stuic-split-button-menu"
266
+ trigger={dropdownTrigger}
267
+ {...menuProps}
268
+ />
269
+ {:else}
270
+ {@render secondary()}
271
+ {/if}
272
+ {/snippet}
273
+
274
+ <div
275
+ bind:this={el}
276
+ class={_class}
277
+ role="group"
278
+ data-placement={!unstyled ? placement : undefined}
279
+ data-divided={!unstyled && divided ? "true" : undefined}
280
+ data-rounded-full={!unstyled && roundedFull ? "true" : undefined}
281
+ data-size={!unstyled ? size : undefined}
282
+ data-open={menuOpen ? "true" : undefined}
283
+ {...rest}
284
+ >
285
+ {#if placement === "start"}
286
+ {@render secondaryRegion()}
287
+ {#if divided}<span class="stuic-split-button-divider" aria-hidden="true"></span>{/if}
288
+ {@render primary()}
289
+ {:else}
290
+ {@render primary()}
291
+ {#if divided}<span class="stuic-split-button-divider" aria-hidden="true"></span>{/if}
292
+ {@render secondaryRegion()}
293
+ {/if}
294
+ </div>
@@ -0,0 +1,98 @@
1
+ import type { HTMLAttributes } from "svelte/elements";
2
+ import type { Snippet } from "svelte";
3
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
4
+ import type { Props as ButtonProps, ButtonVariant, ButtonSize } from "../Button/Button.svelte";
5
+ import type { Props as DropdownMenuProps, DropdownMenuItem, DropdownMenuPosition } from "../DropdownMenu/DropdownMenu.svelte";
6
+ import type { TooltipConfig } from "../../actions/tooltip/tooltip.svelte.js";
7
+ /** Which side of the primary button the secondary (menu) trigger sits on. Logical (RTL-aware). */
8
+ export type SplitButtonPlacement = "start" | "end";
9
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
10
+ /** Primary button content (label and/or custom markup). */
11
+ children?: Snippet<[{
12
+ checked?: boolean;
13
+ }]>;
14
+ /**
15
+ * Convenience primary icon (SVG string or snippet). Rendered before `children`.
16
+ * When provided without `children`, the primary implicitly becomes an icon button.
17
+ */
18
+ icon?: string | Snippet;
19
+ /** Primary click handler. */
20
+ onclick?: (e: MouseEvent) => void;
21
+ /** Color intent forwarded to the primary button. */
22
+ intent?: IntentColorKey;
23
+ /** Visual variant forwarded to the primary button. */
24
+ variant?: ButtonVariant | string;
25
+ /** Size preset forwarded to both segments. */
26
+ size?: ButtonSize | string;
27
+ /** Icon-only primary (forwarded to Button). Auto-enabled when `icon` is set without `children`. */
28
+ iconButton?: boolean;
29
+ /** Toggle state forwarded to the primary button (pairs with `iconSwap`/`roleSwitch`). Bindable. */
30
+ checked?: boolean;
31
+ /** Enable switch/toggle behavior on the primary button. */
32
+ roleSwitch?: boolean;
33
+ /** Two icon states for swap animation on the primary button (implies icon button). */
34
+ iconSwap?: ButtonProps["iconSwap"];
35
+ /** Disable the primary button only (see `secondaryDisabled` for the trigger). */
36
+ disabled?: boolean;
37
+ /** Tooltip for the primary button (content string or config). */
38
+ tooltip?: string | TooltipConfig;
39
+ /**
40
+ * Escape hatch: any extra `ButtonProps` forwarded to the primary button.
41
+ * Wins over the top-level conveniences above.
42
+ */
43
+ primaryProps?: Partial<ButtonProps>;
44
+ /** Classes for the primary button. */
45
+ classPrimary?: string;
46
+ /** Bindable primary button element reference. */
47
+ primaryEl?: HTMLElement;
48
+ /** Which side the secondary trigger sits on (default `"start"` — the raised/pill look). */
49
+ placement?: SplitButtonPlacement;
50
+ /** Secondary trigger icon (SVG string or snippet). Defaults to a chevron that rotates when open. */
51
+ secondaryIcon?: string | Snippet;
52
+ /** Accessible label for the secondary trigger (default `"More options"`). */
53
+ secondaryLabel?: string;
54
+ /** Tooltip for the secondary trigger (content string or config). */
55
+ secondaryTooltip?: string | TooltipConfig;
56
+ /** Disable the secondary trigger only. */
57
+ secondaryDisabled?: boolean;
58
+ /**
59
+ * Custom secondary click handler — used INSTEAD of the built-in dropdown.
60
+ * Only relevant when `items` is not provided (`items` takes precedence).
61
+ */
62
+ onSecondaryClick?: (e: MouseEvent) => void;
63
+ /** Escape hatch: any extra `ButtonProps` forwarded to the secondary trigger button. */
64
+ secondaryProps?: Partial<ButtonProps>;
65
+ /** Classes for the secondary trigger button. */
66
+ classSecondary?: string;
67
+ /** Bindable secondary trigger element reference. */
68
+ secondaryEl?: HTMLElement;
69
+ /** Menu items — when provided, the secondary trigger opens a `DropdownMenu`. */
70
+ items?: DropdownMenuItem[];
71
+ /** Bindable menu open state. */
72
+ menuOpen?: boolean;
73
+ /** Menu position relative to the secondary trigger. */
74
+ position?: DropdownMenuPosition;
75
+ /** Menu offset from the secondary trigger (CSS value). */
76
+ offset?: string;
77
+ /**
78
+ * Escape hatch: any extra `DropdownMenuProps` forwarded to the `DropdownMenu`.
79
+ * Wins over the top-level `position`/`offset` conveniences.
80
+ */
81
+ menuProps?: Partial<Omit<DropdownMenuProps, "items" | "trigger" | "isOpen" | "triggerEl" | "class">>;
82
+ /**
83
+ * Classic split-button look: flush segments + hairline divider — instead of the
84
+ * default "raised" look (a distinct primary button on a shared pill surface).
85
+ */
86
+ divided?: boolean;
87
+ /** Fully-rounded pill (default `true`). Set `false` to use the button radius. */
88
+ roundedFull?: boolean;
89
+ /** Skip all default styling, use only custom classes */
90
+ unstyled?: boolean;
91
+ /** Additional CSS classes for the wrapper. */
92
+ class?: string;
93
+ /** Bindable wrapper element reference. */
94
+ el?: HTMLDivElement;
95
+ }
96
+ declare const SplitButton: import("svelte").Component<Props, {}, "el" | "checked" | "primaryEl" | "secondaryEl" | "menuOpen">;
97
+ type SplitButton = ReturnType<typeof SplitButton>;
98
+ export default SplitButton;
@@ -0,0 +1,95 @@
1
+ /* =============================================================================
2
+ SPLIT BUTTON COMPONENT TOKENS
3
+ Override globally: :root { --stuic-split-button-bg: red; }
4
+ Override locally: <SplitButton style="--stuic-split-button-bg: red;">
5
+ ============================================================================= */
6
+
7
+ :root {
8
+ /* Raised (default) mode: shared pill surface behind both segments */
9
+ --stuic-split-button-bg: var(--stuic-color-surface);
10
+ --stuic-split-button-padding: calc(var(--spacing) * 0); /* mm: no padding by default */
11
+ --stuic-split-button-gap: calc(var(--spacing) * 0); /* mm: no gap by default */
12
+
13
+ /* Divider (divided mode only) */
14
+ --stuic-split-button-divider-bg: var(--stuic-color-border);
15
+ --stuic-split-button-divider-width: 1px;
16
+ --stuic-split-button-divider-inset: calc(var(--spacing) * 1.5);
17
+ }
18
+
19
+ @layer components {
20
+ /* ============================================================================
21
+ BASE (raised mode: distinct primary button on a shared pill surface)
22
+ ============================================================================ */
23
+
24
+ .stuic-split-button {
25
+ /* structural token resolved at element level (scoped overrides work) */
26
+ --_sb-radius: var(--stuic-split-button-radius, var(--stuic-radius-button));
27
+
28
+ position: relative;
29
+ display: inline-flex;
30
+ align-items: center;
31
+ gap: var(--stuic-split-button-gap);
32
+ padding: var(--stuic-split-button-padding);
33
+ background: var(--stuic-split-button-bg);
34
+ border-radius: var(--_sb-radius);
35
+ }
36
+
37
+ .stuic-split-button[data-rounded-full] {
38
+ --_sb-radius: 9999px;
39
+ }
40
+
41
+ /* DropdownMenu wrapper participates in the flex row */
42
+ .stuic-split-button > .stuic-split-button-menu {
43
+ display: inline-flex;
44
+ }
45
+
46
+ /* Icon wrappers center their svg content */
47
+ .stuic-split-button-icon,
48
+ .stuic-split-button-caret {
49
+ display: inline-flex;
50
+ }
51
+
52
+ /* Default caret rotates when the menu is open */
53
+ .stuic-split-button-caret {
54
+ transition: rotate var(--stuic-split-button-transition, var(--stuic-transition));
55
+ }
56
+ .stuic-split-button[data-open] .stuic-split-button-caret {
57
+ rotate: 180deg;
58
+ }
59
+
60
+ /* ============================================================================
61
+ DIVIDED MODE (classic split button: flush segments + hairline divider)
62
+ ============================================================================ */
63
+
64
+ .stuic-split-button[data-divided] {
65
+ gap: 0;
66
+ padding: 0;
67
+ background: transparent;
68
+ }
69
+
70
+ .stuic-split-button[data-divided] .stuic-split-button-divider {
71
+ align-self: stretch;
72
+ width: var(--stuic-split-button-divider-width);
73
+ margin-block: var(--stuic-split-button-divider-inset);
74
+ background: var(--stuic-split-button-divider-bg);
75
+ }
76
+
77
+ /* Flush inner corners, keep outer corners (placement-aware, logical = RTL-safe) */
78
+ .stuic-split-button[data-divided] .stuic-split-button-primary,
79
+ .stuic-split-button[data-divided] .stuic-split-button-secondary {
80
+ border-radius: 0;
81
+ }
82
+
83
+ .stuic-split-button[data-divided][data-placement="end"] .stuic-split-button-primary,
84
+ .stuic-split-button[data-divided][data-placement="start"]
85
+ .stuic-split-button-secondary {
86
+ border-start-start-radius: var(--_sb-radius);
87
+ border-end-start-radius: var(--_sb-radius);
88
+ }
89
+
90
+ .stuic-split-button[data-divided][data-placement="end"] .stuic-split-button-secondary,
91
+ .stuic-split-button[data-divided][data-placement="start"] .stuic-split-button-primary {
92
+ border-start-end-radius: var(--_sb-radius);
93
+ border-end-end-radius: var(--_sb-radius);
94
+ }
95
+ }
@@ -0,0 +1 @@
1
+ export { default as SplitButton, type Props as SplitButtonProps, type SplitButtonPlacement, } from "./SplitButton.svelte";
@@ -0,0 +1 @@
1
+ export { default as SplitButton, } from "./SplitButton.svelte";
package/dist/index.css CHANGED
@@ -100,6 +100,7 @@ In practice:
100
100
  @import "./components/Skeleton/index.css";
101
101
  @import "./components/Slider/index.css";
102
102
  @import "./components/Spinner/index.css";
103
+ @import "./components/SplitButton/index.css";
103
104
  @import "./components/Switch/index.css";
104
105
  @import "./components/TabbedMenu/index.css";
105
106
  @import "./components/ThemePreview/index.css";
package/dist/index.d.ts CHANGED
@@ -70,6 +70,7 @@ export * from "./components/Skeleton/index.js";
70
70
  export * from "./components/Slider/index.js";
71
71
  export * from "./components/SlidingPanels/index.js";
72
72
  export * from "./components/Spinner/index.js";
73
+ export * from "./components/SplitButton/index.js";
73
74
  export * from "./components/Switch/index.js";
74
75
  export * from "./components/TabbedMenu/index.js";
75
76
  export * from "./components/Thc/index.js";
package/dist/index.js CHANGED
@@ -76,6 +76,7 @@ export * from "./components/Skeleton/index.js";
76
76
  export * from "./components/Slider/index.js";
77
77
  export * from "./components/SlidingPanels/index.js";
78
78
  export * from "./components/Spinner/index.js";
79
+ export * from "./components/SplitButton/index.js";
79
80
  export * from "./components/Switch/index.js";
80
81
  export * from "./components/TabbedMenu/index.js";
81
82
  export * from "./components/Thc/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.148.0",
3
+ "version": "3.149.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -129,61 +129,61 @@
129
129
  },
130
130
  "devDependencies": {
131
131
  "@codemirror/commands": "^6.10.4",
132
- "@codemirror/lang-markdown": "^6.5.0",
132
+ "@codemirror/lang-markdown": "^6.5.2",
133
133
  "@codemirror/language": "^6.12.4",
134
134
  "@codemirror/language-data": "^6.5.2",
135
- "@codemirror/state": "^6.7.0",
136
- "@codemirror/view": "^6.43.4",
137
- "@eslint/js": "^9.39.4",
135
+ "@codemirror/state": "^6.7.1",
136
+ "@codemirror/view": "^6.43.8",
137
+ "@eslint/js": "^9.39.5",
138
138
  "@marianmeres/random-human-readable": "^1.10.2",
139
- "@milkdown/core": "^7.21.2",
140
- "@milkdown/ctx": "^7.21.2",
141
- "@milkdown/plugin-history": "^7.21.2",
142
- "@milkdown/plugin-listener": "^7.21.2",
143
- "@milkdown/preset-commonmark": "^7.21.2",
144
- "@milkdown/preset-gfm": "^7.21.2",
145
- "@milkdown/prose": "^7.21.2",
146
- "@milkdown/transformer": "^7.21.2",
147
- "@milkdown/utils": "^7.21.2",
139
+ "@milkdown/core": "^7.22.0",
140
+ "@milkdown/ctx": "^7.22.0",
141
+ "@milkdown/plugin-history": "^7.22.0",
142
+ "@milkdown/plugin-listener": "^7.22.0",
143
+ "@milkdown/preset-commonmark": "^7.22.0",
144
+ "@milkdown/preset-gfm": "^7.22.0",
145
+ "@milkdown/prose": "^7.22.0",
146
+ "@milkdown/transformer": "^7.22.0",
147
+ "@milkdown/utils": "^7.22.0",
148
148
  "@sveltejs/adapter-auto": "^4.0.0",
149
- "@sveltejs/kit": "^2.68.0",
149
+ "@sveltejs/kit": "^2.70.2",
150
150
  "@sveltejs/package": "^2.5.8",
151
151
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
152
- "@tailwindcss/cli": "^4.3.1",
152
+ "@tailwindcss/cli": "^4.3.3",
153
153
  "@tailwindcss/forms": "^0.5.11",
154
154
  "@tailwindcss/typography": "^0.5.20",
155
- "@tailwindcss/vite": "^4.3.1",
156
- "@types/node": "^25.9.4",
157
- "@vitest/browser-playwright": "^4.1.9",
155
+ "@tailwindcss/vite": "^4.3.3",
156
+ "@types/node": "^25.9.5",
157
+ "@vitest/browser-playwright": "^4.1.10",
158
158
  "dotenv": "^16.6.1",
159
- "eslint": "^9.39.4",
159
+ "eslint": "^9.39.5",
160
160
  "globals": "^16.5.0",
161
- "playwright": "^1.61.1",
162
- "prettier": "^3.9.0",
161
+ "playwright": "^1.62.1",
162
+ "prettier": "^3.9.6",
163
163
  "prettier-plugin-svelte": "^3.5.2",
164
- "publint": "^0.3.21",
165
- "svelte": "^5.56.4",
166
- "svelte-check": "^4.7.1",
167
- "tailwindcss": "^4.3.1",
168
- "tsx": "^4.22.4",
164
+ "publint": "^0.3.23",
165
+ "svelte": "^5.56.8",
166
+ "svelte-check": "^4.7.5",
167
+ "tailwindcss": "^4.3.3",
168
+ "tsx": "^4.23.12",
169
169
  "typescript": "^5.9.3",
170
- "typescript-eslint": "^8.62.0",
170
+ "typescript-eslint": "^8.67.0",
171
171
  "vite": "^7.3.6",
172
- "vitest": "^4.1.9",
173
- "vitest-browser-svelte": "^2.1.1"
172
+ "vitest": "^4.1.10",
173
+ "vitest-browser-svelte": "^2.2.1"
174
174
  },
175
175
  "dependencies": {
176
176
  "@marianmeres/clog": "^3.21.0",
177
- "@marianmeres/countries": "^1.0.1",
177
+ "@marianmeres/countries": "^1.1.0",
178
178
  "@marianmeres/cron-parser": "^1.0.1",
179
- "@marianmeres/design-tokens": "^1.12.0",
179
+ "@marianmeres/design-tokens": "^1.17.0",
180
180
  "@marianmeres/icons-fns": "^5.0.0",
181
181
  "@marianmeres/item-collection": "^1.4.2",
182
182
  "@marianmeres/paging-store": "^2.1.1",
183
183
  "@marianmeres/parse-boolean": "^2.1.0",
184
184
  "@marianmeres/ticker": "^1.17.1",
185
185
  "@marianmeres/tree": "^2.3.0",
186
- "libphonenumber-js": "^1.13.7",
186
+ "libphonenumber-js": "^1.13.10",
187
187
  "runed": "^0.23.4",
188
188
  "tailwind-merge": "^3.6.0"
189
189
  }