@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.
- package/dist/actions/tooltip/README.md +1 -1
- package/dist/actions/tooltip/tooltip.svelte.js +4 -3
- package/dist/components/Slider/README.md +52 -33
- package/dist/components/Slider/Slider.svelte +29 -8
- package/dist/components/Slider/Slider.svelte.d.ts +10 -0
- package/dist/components/Slider/index.css +15 -9
- package/dist/components/SplitButton/README.md +163 -0
- package/dist/components/SplitButton/SplitButton.svelte +294 -0
- package/dist/components/SplitButton/SplitButton.svelte.d.ts +98 -0
- package/dist/components/SplitButton/index.css +95 -0
- package/dist/components/SplitButton/index.d.ts +1 -0
- package/dist/components/SplitButton/index.js +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +33 -33
|
@@ -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
|
|
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
|
-
| `
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
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
|
|
120
|
-
|
|
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
|
-
//
|
|
169
|
-
//
|
|
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
|
|
262
|
-
// (mirrors the CSS `(100% - thickness) * ratio` positioning); without
|
|
263
|
-
//
|
|
264
|
-
// cross-axis size equals --_thickness — size the cross-axis via the
|
|
265
|
-
// presets or --stuic-slider-thickness, not via utility classes (see README).
|
|
266
|
-
const pad =
|
|
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
|
|
120
|
-
|
|
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
|
|
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
|
|
253
|
-
.stuic-slider[data-thumb-
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
136
|
-
"@codemirror/view": "^6.43.
|
|
137
|
-
"@eslint/js": "^9.39.
|
|
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.
|
|
140
|
-
"@milkdown/ctx": "^7.
|
|
141
|
-
"@milkdown/plugin-history": "^7.
|
|
142
|
-
"@milkdown/plugin-listener": "^7.
|
|
143
|
-
"@milkdown/preset-commonmark": "^7.
|
|
144
|
-
"@milkdown/preset-gfm": "^7.
|
|
145
|
-
"@milkdown/prose": "^7.
|
|
146
|
-
"@milkdown/transformer": "^7.
|
|
147
|
-
"@milkdown/utils": "^7.
|
|
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.
|
|
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.
|
|
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.
|
|
156
|
-
"@types/node": "^25.9.
|
|
157
|
-
"@vitest/browser-playwright": "^4.1.
|
|
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.
|
|
159
|
+
"eslint": "^9.39.5",
|
|
160
160
|
"globals": "^16.5.0",
|
|
161
|
-
"playwright": "^1.
|
|
162
|
-
"prettier": "^3.9.
|
|
161
|
+
"playwright": "^1.62.1",
|
|
162
|
+
"prettier": "^3.9.6",
|
|
163
163
|
"prettier-plugin-svelte": "^3.5.2",
|
|
164
|
-
"publint": "^0.3.
|
|
165
|
-
"svelte": "^5.56.
|
|
166
|
-
"svelte-check": "^4.7.
|
|
167
|
-
"tailwindcss": "^4.3.
|
|
168
|
-
"tsx": "^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.
|
|
170
|
+
"typescript-eslint": "^8.67.0",
|
|
171
171
|
"vite": "^7.3.6",
|
|
172
|
-
"vitest": "^4.1.
|
|
173
|
-
"vitest-browser-svelte": "^2.
|
|
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
|
|
177
|
+
"@marianmeres/countries": "^1.1.0",
|
|
178
178
|
"@marianmeres/cron-parser": "^1.0.1",
|
|
179
|
-
"@marianmeres/design-tokens": "^1.
|
|
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.
|
|
186
|
+
"libphonenumber-js": "^1.13.10",
|
|
187
187
|
"runed": "^0.23.4",
|
|
188
188
|
"tailwind-merge": "^3.6.0"
|
|
189
189
|
}
|