@marianmeres/stuic 3.146.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");
@@ -10,37 +10,38 @@ variant for custom UI (volume/brightness controls, dashboards, media players).
10
10
 
11
11
  ## Props
12
12
 
13
- | Prop | Type | Default | Description |
14
- | --------------- | ------------------------------------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
15
- | `value` | `number` | `min` | Current value (bindable; non-finite / out-of-range / off-grid writes are normalized back) |
16
- | `min` | `number` | `0` | Minimum value |
17
- | `max` | `number` | `100` | Maximum value |
18
- | `step` | `number \| "any"` | `1` | Snap increment (`"any"` or non-positive = continuous) |
19
- | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Slider direction (vertical fills bottom-up) |
20
- | `size` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Cross-axis thickness preset |
21
- | `intent` | `"primary" \| "accent" \| "success" \| "warning" \| "destructive"` | - | Semantic fill color |
22
- | `thumb` | `boolean \| Snippet<[SliderRenderCtx]>` | `true` | `false` hides the thumb (fill-only look), snippet renders inside thumb |
23
- | `thumbPosition` | `"value" \| "start"` | `"value"` | `"value"` rides the fill edge; `"start"` pins it to the left/bottom so only the bar moves (true iOS volume look) |
24
- | `fillRounded` | `boolean` | `false` | Round the fill's leading edge ("pill inside a pill") |
25
- | `ticks` | `boolean \| number[]` | - | `true` = tick at every `step` (positive numeric step only; skipped above 101 auto ticks — pass an array), array = ticks at given in-range values |
26
- | `valueLabel` | `Snippet<[SliderRenderCtx]>` | - | Floating label at the current value along the track |
27
- | `disabled` | `boolean` | `false` | Disable interaction |
28
- | `label` | `string` | - | Screen reader label for the underlying input |
29
- | `id` | `string` | - | Id for the underlying input (enables `<label for>` association) |
30
- | `name` | `string` | - | Form field name for the hidden range input |
31
- | `required` | `boolean` | `false` | Forwarded to the input; per HTML spec inert on range inputs (use `validate.customValidator` for custom rules) |
32
- | `oninput` | `(value: number) => void` | - | Fires on every value change (drag, keyboard) |
33
- | `onchange` | `(value: number) => void` | - | Fires when a change is committed (drag release, keyboard) |
34
- | `validate` | `boolean \| ValidateOptions` | - | Enable validation (stuic validate action) |
35
- | `unstyled` | `boolean` | `false` | Skip all default styling |
36
- | `class` | `string` | - | Classes for the root element |
37
- | `trackClass` | `string` | - | Classes for the track (pill background) |
38
- | `fillClass` | `string` | - | Classes for the fill (value indicator) |
39
- | `thumbClass` | `string` | - | Classes for the thumb |
40
- | `tickClass` | `string` | - | Classes for each tick mark |
41
- | `valueClass` | `string` | - | Classes for the value label wrapper |
42
- | `el` | `HTMLDivElement` | - | Root element reference (bindable) |
43
- | `inputEl` | `HTMLInputElement` | - | Hidden range input reference (bindable) |
13
+ | Prop | Type | Default | Description |
14
+ | --------------- | ------------------------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | `value` | `number` | `min` | Current value (bindable; non-finite / out-of-range / off-grid writes are normalized back) |
16
+ | `min` | `number` | `0` | Minimum value |
17
+ | `max` | `number` | `100` | Maximum value |
18
+ | `step` | `number \| "any"` | `1` | Snap increment (`"any"` or non-positive = continuous) |
19
+ | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Slider direction (vertical fills bottom-up) |
20
+ | `size` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Cross-axis thickness preset |
21
+ | `intent` | `"primary" \| "accent" \| "success" \| "warning" \| "destructive"` | - | Semantic fill color |
22
+ | `thumb` | `boolean \| Snippet<[SliderRenderCtx]>` | `true` | `false` hides the thumb (fill-only look), snippet renders inside thumb |
23
+ | `thumbPosition` | `"value" \| "start"` | `"value"` | `"value"` rides the fill edge; `"start"` pins it to the left/bottom so only the bar moves (true iOS volume look) |
24
+ | `thumbReserve` | `boolean` | `true` | Reserve the thumb's footprint along the track, so the fill never shrinks below the thumb. `false` = linear mapping across the full track (fill collapses to zero at `min`); only honored with `thumbPosition="start"` |
25
+ | `fillRounded` | `boolean` | `false` | Round the fill's leading edge ("pill inside a pill") |
26
+ | `ticks` | `boolean \| number[]` | - | `true` = tick at every `step` (positive numeric step only; skipped above 101 auto ticks — pass an array), array = ticks at given in-range values |
27
+ | `valueLabel` | `Snippet<[SliderRenderCtx]>` | - | Floating label at the current value along the track |
28
+ | `disabled` | `boolean` | `false` | Disable interaction |
29
+ | `label` | `string` | - | Screen reader label for the underlying input |
30
+ | `id` | `string` | - | Id for the underlying input (enables `<label for>` association) |
31
+ | `name` | `string` | - | Form field name for the hidden range input |
32
+ | `required` | `boolean` | `false` | Forwarded to the input; per HTML spec inert on range inputs (use `validate.customValidator` for custom rules) |
33
+ | `oninput` | `(value: number) => void` | - | Fires on every value change (drag, keyboard) |
34
+ | `onchange` | `(value: number) => void` | - | Fires when a change is committed (drag release, keyboard) |
35
+ | `validate` | `boolean \| ValidateOptions` | - | Enable validation (stuic validate action) |
36
+ | `unstyled` | `boolean` | `false` | Skip all default styling |
37
+ | `class` | `string` | - | Classes for the root element |
38
+ | `trackClass` | `string` | - | Classes for the track (pill background) |
39
+ | `fillClass` | `string` | - | Classes for the fill (value indicator) |
40
+ | `thumbClass` | `string` | - | Classes for the thumb |
41
+ | `tickClass` | `string` | - | Classes for each tick mark |
42
+ | `valueClass` | `string` | - | Classes for the value label wrapper |
43
+ | `el` | `HTMLDivElement` | - | Root element reference (bindable) |
44
+ | `inputEl` | `HTMLInputElement` | - | Hidden range input reference (bindable) |
44
45
 
45
46
  `SliderRenderCtx` (passed to the `thumb` and `valueLabel` snippets):
46
47
  `{ value: number; ratio: number /* 0..1 */; percent: number /* 0..100 */; dragging: boolean }`
@@ -92,6 +93,9 @@ Remaining props are spread onto the root `<div>`.
92
93
  <Slider thumb={false} bind:value={brightness} />
93
94
  ```
94
95
 
96
+ With no thumb there is nothing to reserve: the value maps linearly across the whole
97
+ track and the fill collapses to zero at `min`.
98
+
95
99
  ### True iOS volume: fixed icon at the start, only the bar moves
96
100
 
97
101
  ```svelte
@@ -116,8 +120,19 @@ Remaining props are spread onto the root `<div>`.
116
120
  </Slider>
117
121
  ```
118
122
 
119
- With `thumbPosition="start"` the thumb is decorative (`pointer-events: none`) and the
120
- value maps linearly across the full track there is no thumb-travel inset.
123
+ With `thumbPosition="start"` the thumb never moves only the bar does. It is purely
124
+ decorative (the full-size hidden input is always the topmost hit target, so the thumb
125
+ never receives pointer events), but it still **reserves its footprint** along the track:
126
+ the fill can never shrink below it, and at `min` it is exactly a thumb-sized nub — with
127
+ `fillRounded`, a circle enclosing the pinned thumb, same as the default
128
+ `thumbPosition="value"` look.
129
+
130
+ Opt out with `thumbReserve={false}` for a linear mapping across the full track, where
131
+ the fill collapses to zero at `min` (and, at low values, is shorter than the thumb):
132
+
133
+ ```svelte
134
+ <Slider thumbPosition="start" thumbReserve={false} bind:value={volume} />
135
+ ```
121
136
 
122
137
  ### Steps and ticks
123
138
 
@@ -156,6 +171,10 @@ value maps linearly across the full track — there is no thumb-travel inset.
156
171
  the thumb itself does not jump (drag continues from the grab point).
157
172
  - **Keyboard**: focus and use Arrow keys / PageUp / PageDown / Home / End — native
158
173
  `input[type=range]` behavior (the real input is visually hidden but focusable).
174
+ - **Thumb reserve**: with a thumb rendered (traveling or pinned) the value maps onto the
175
+ thumb-center travel — the outer half-thumb at each end of the track resolves to
176
+ `min`/`max`, and the fill never shrinks below the thumb. `thumb={false}` or
177
+ `thumbReserve={false}` maps linearly across the whole track instead.
159
178
  - **Vertical**: bottom is `min`, top is `max`; ArrowUp increases.
160
179
  - **RTL**: horizontal sliders flip automatically (logical CSS properties + pointer math).
161
180
  - **Commit semantics**: `oninput` fires only on actual value changes; `onchange` only
@@ -89,6 +89,16 @@
89
89
  * the true iOS volume look with a fixed icon.
90
90
  */
91
91
  thumbPosition?: SliderThumbPosition;
92
+ /**
93
+ * Reserve the thumb's footprint at both ends of the track: the value maps
94
+ * onto the thumb-center travel, so the fill can never shrink below the
95
+ * thumb (at `min` it is exactly a thumb-sized nub). `false` maps the value
96
+ * linearly across the whole track instead, letting the fill collapse to
97
+ * zero — only honored with `thumbPosition="start"` (a traveling thumb's
98
+ * position is defined against the reserved travel) and irrelevant without
99
+ * a thumb (`thumb={false}` never reserves).
100
+ */
101
+ thumbReserve?: boolean;
92
102
  /**
93
103
  * Round the fill's leading edge (instead of a flat cut), giving the
94
104
  * "pill inside a pill" look.
@@ -141,6 +151,7 @@
141
151
  valueClass,
142
152
  thumb = true,
143
153
  thumbPosition = "value",
154
+ thumbReserve = true,
144
155
  fillRounded = false,
145
156
  ticks,
146
157
  valueLabel,
@@ -165,9 +176,18 @@
165
176
  // Value at drag start — commit (change) fires only if the drag changed it.
166
177
  let _dragStartValue = 0;
167
178
 
168
- // Only a value-tracking thumb insets the usable travel; a start-pinned thumb
169
- // (or none) lets the fill map linearly across the whole track.
179
+ // Two related but distinct concepts:
180
+ // - _thumbTravels: the thumb rides the fill edge (drives only its CSS position
181
+ // and the grab-offset detection).
182
+ // - _thumbReserved: the thumb's footprint is reserved at both ends, so the
183
+ // value maps onto the thumb-center travel and the fill/ticks/value label
184
+ // follow that same travel. This is what keeps the fill from ever shrinking
185
+ // below the thumb (at min it is exactly a thumb-sized nub).
186
+ // A traveling thumb always reserves (its position is defined against that
187
+ // travel); a pinned one reserves by default but can opt out via
188
+ // `thumbReserve={false}`, and no thumb never reserves.
170
189
  let _thumbTravels = $derived(thumb !== false && thumbPosition === "value");
190
+ let _thumbReserved = $derived(thumb !== false && (_thumbTravels || thumbReserve));
171
191
 
172
192
  // Bounds must be finite before anything else: a NaN bound would make every
173
193
  // normalization write NaN, and NaN !== NaN would re-trigger the effect below
@@ -258,12 +278,12 @@
258
278
  const rtl = horizontal && getComputedStyle(el!).direction === "rtl";
259
279
  const trackLen = horizontal ? rect.width : rect.height;
260
280
  const thickness = horizontal ? rect.height : rect.width;
261
- // With a thumb, the thumb center travels within [thickness/2, len - thickness/2]
262
- // (mirrors the CSS `(100% - thickness) * ratio` positioning); without it, the
263
- // value maps linearly across the whole track. NOTE: this assumes the rendered
264
- // cross-axis size equals --_thickness — size the cross-axis via the size
265
- // presets or --stuic-slider-thickness, not via utility classes (see README).
266
- const pad = _thumbTravels ? thickness / 2 : 0;
281
+ // With a reserved thumb the value maps onto [thickness/2, len - thickness/2]
282
+ // (mirrors the CSS `(100% - thickness) * ratio` sizing/positioning); without
283
+ // the reserve it maps linearly across the whole track. NOTE: this assumes the
284
+ // rendered cross-axis size equals --_thickness — size the cross-axis via the
285
+ // size presets or --stuic-slider-thickness, not via utility classes (see README).
286
+ const pad = _thumbReserved ? thickness / 2 : 0;
267
287
  const travel = Math.max(1, trackLen - 2 * pad);
268
288
  return { horizontal, rtl, rect, trackLen, pad, travel };
269
289
  }
@@ -426,6 +446,7 @@
426
446
  data-thumb={thumb !== false ? "true" : "false"}
427
447
  data-thumb-position={thumb !== false ? thumbPosition : undefined}
428
448
  data-thumb-travels={_thumbTravels ? "true" : "false"}
449
+ data-thumb-reserved={_thumbReserved ? "true" : "false"}
429
450
  data-fill-rounded={fillRounded ? "true" : undefined}
430
451
  data-size={!unstyled ? size : undefined}
431
452
  data-intent={!unstyled ? intent : undefined}
@@ -69,6 +69,16 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children" |
69
69
  * the true iOS volume look with a fixed icon.
70
70
  */
71
71
  thumbPosition?: SliderThumbPosition;
72
+ /**
73
+ * Reserve the thumb's footprint at both ends of the track: the value maps
74
+ * onto the thumb-center travel, so the fill can never shrink below the
75
+ * thumb (at `min` it is exactly a thumb-sized nub). `false` maps the value
76
+ * linearly across the whole track instead, letting the fill collapse to
77
+ * zero — only honored with `thumbPosition="start"` (a traveling thumb's
78
+ * position is defined against the reserved travel) and irrelevant without
79
+ * a thumb (`thumb={false}` never reserves).
80
+ */
81
+ thumbReserve?: boolean;
72
82
  /**
73
83
  * Round the fill's leading edge (instead of a flat cut), giving the
74
84
  * "pill inside a pill" look.
@@ -116,8 +116,12 @@
116
116
  position: absolute;
117
117
  background: var(--_fill);
118
118
  }
119
- /* With a value-tracking thumb, the fill reaches the far edge of the thumb */
120
- .stuic-slider[data-thumb-travels="true"] {
119
+ /* With a reserved thumb (traveling OR pinned), the fill spans the thumb-center
120
+ travel plus the thumb itself: it reaches the far edge of a traveling thumb and,
121
+ at ratio 0, is exactly one thickness — a thumb-sized nub that the fill can
122
+ never shrink below. `thumbReserve={false}` (and no thumb) falls back to the
123
+ plain linear `ratio * 100%` above, which collapses to zero at min. */
124
+ .stuic-slider[data-thumb-reserved="true"] {
121
125
  --_fill-len: calc(var(--_ratio) * (100% - var(--_thickness)) + var(--_thickness));
122
126
  }
123
127
  .stuic-slider[data-orientation="horizontal"] .fill {
@@ -164,7 +168,9 @@
164
168
  bottom: var(--_thumb-inset);
165
169
  }
166
170
  /* thumbPosition="value": ride the fill edge (default). thumbPosition="start"
167
- keeps the offsets above, so only the bar moves (iOS volume look). */
171
+ keeps the offsets above, so only the bar moves (iOS volume look) — note this
172
+ is data-thumb-travels, NOT data-thumb-reserved: a pinned thumb reserves the
173
+ travel (for the fill/ticks/value label) without moving itself. */
168
174
  .stuic-slider[data-thumb-travels="true"][data-orientation="horizontal"] > .thumb {
169
175
  inset-inline-start: calc(
170
176
  var(--_thumb-inset) + (100% - var(--_thickness)) * var(--_ratio)
@@ -234,7 +240,7 @@
234
240
  border-radius: 9999px;
235
241
  background: var(--_tick);
236
242
  }
237
- /* Without a traveling thumb there is no thickness inset to hide behind, so the
243
+ /* Without a reserved thumb there is no thickness inset to hide behind, so the
238
244
  travel is inset by half a tick — otherwise the ticks at ratio 0 and 1 sit
239
245
  centered on the track edges and the overflow:hidden track halves them. */
240
246
  .stuic-slider[data-orientation="horizontal"] .tick {
@@ -249,13 +255,13 @@
249
255
  bottom: calc(var(--_tick-size) / 2 + (100% - var(--_tick-size)) * var(--_tick-ratio));
250
256
  transform: translate(-50%, 50%);
251
257
  }
252
- /* With a value-tracking thumb, ticks align to the thumb-center travel */
253
- .stuic-slider[data-thumb-travels="true"][data-orientation="horizontal"] .tick {
258
+ /* With a reserved thumb, ticks align to the thumb-center travel */
259
+ .stuic-slider[data-thumb-reserved="true"][data-orientation="horizontal"] .tick {
254
260
  inset-inline-start: calc(
255
261
  var(--_thickness) / 2 + (100% - var(--_thickness)) * var(--_tick-ratio)
256
262
  );
257
263
  }
258
- .stuic-slider[data-thumb-travels="true"][data-orientation="vertical"] .tick {
264
+ .stuic-slider[data-thumb-reserved="true"][data-orientation="vertical"] .tick {
259
265
  bottom: calc(var(--_thickness) / 2 + (100% - var(--_thickness)) * var(--_tick-ratio));
260
266
  }
261
267
  /* Logical-property positioning + physical translate: flip centering in RTL */
@@ -284,12 +290,12 @@
284
290
  bottom: calc(var(--_ratio) * 100%);
285
291
  transform: translateY(50%);
286
292
  }
287
- .stuic-slider[data-thumb-travels="true"][data-orientation="horizontal"] > .value {
293
+ .stuic-slider[data-thumb-reserved="true"][data-orientation="horizontal"] > .value {
288
294
  inset-inline-start: calc(
289
295
  var(--_thickness) / 2 + (100% - var(--_thickness)) * var(--_ratio)
290
296
  );
291
297
  }
292
- .stuic-slider[data-thumb-travels="true"][data-orientation="vertical"] > .value {
298
+ .stuic-slider[data-thumb-reserved="true"][data-orientation="vertical"] > .value {
293
299
  bottom: calc(var(--_thickness) / 2 + (100% - var(--_thickness)) * var(--_ratio));
294
300
  }
295
301
  .stuic-slider[data-orientation="horizontal"]:dir(rtl) > .value {
@@ -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.146.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
  }