@marianmeres/stuic 3.172.0 → 3.174.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.
Files changed (61) hide show
  1. package/AGENTS.md +19 -4
  2. package/API.md +56 -2
  3. package/README.md +21 -5
  4. package/dist/actions/resizable-width.svelte.d.ts +14 -28
  5. package/dist/actions/resizable-width.svelte.js +16 -171
  6. package/dist/attachments/index.d.ts +1 -0
  7. package/dist/attachments/index.js +1 -0
  8. package/dist/attachments/resizable.d.ts +113 -0
  9. package/dist/attachments/resizable.fixture.svelte +53 -0
  10. package/dist/attachments/resizable.fixture.svelte.d.ts +10 -0
  11. package/dist/attachments/resizable.js +295 -0
  12. package/dist/components/Button/README.md +11 -10
  13. package/dist/components/ButtonGroupRadio/README.md +52 -23
  14. package/dist/components/ButtonGroupRadio/index.css +6 -2
  15. package/dist/components/PricingTable/PricingTable.svelte +8 -14
  16. package/dist/components/PricingTable/README.md +29 -6
  17. package/dist/components/PricingTable/index.css +54 -0
  18. package/dist/components/RangeSlider/README.md +291 -0
  19. package/dist/components/RangeSlider/RangeSlider.svelte +763 -0
  20. package/dist/components/RangeSlider/RangeSlider.svelte.d.ts +130 -0
  21. package/dist/components/RangeSlider/i18n-sk.d.ts +17 -0
  22. package/dist/components/RangeSlider/i18n-sk.js +19 -0
  23. package/dist/components/RangeSlider/i18n.d.ts +33 -0
  24. package/dist/components/RangeSlider/i18n.js +41 -0
  25. package/dist/components/RangeSlider/index.css +430 -0
  26. package/dist/components/RangeSlider/index.d.ts +3 -0
  27. package/dist/components/RangeSlider/index.js +3 -0
  28. package/dist/components/Rating/README.md +206 -0
  29. package/dist/components/Rating/Rating.svelte +355 -0
  30. package/dist/components/Rating/Rating.svelte.d.ts +82 -0
  31. package/dist/components/Rating/i18n-sk.d.ts +17 -0
  32. package/dist/components/Rating/i18n-sk.js +21 -0
  33. package/dist/components/Rating/i18n.d.ts +34 -0
  34. package/dist/components/Rating/i18n.js +42 -0
  35. package/dist/components/Rating/index.css +170 -0
  36. package/dist/components/Rating/index.d.ts +3 -0
  37. package/dist/components/Rating/index.js +3 -0
  38. package/dist/components/SplitPane/README.md +169 -0
  39. package/dist/components/SplitPane/SplitPane.svelte +202 -0
  40. package/dist/components/SplitPane/SplitPane.svelte.d.ts +67 -0
  41. package/dist/components/SplitPane/i18n-sk.d.ts +17 -0
  42. package/dist/components/SplitPane/i18n-sk.js +18 -0
  43. package/dist/components/SplitPane/i18n.d.ts +31 -0
  44. package/dist/components/SplitPane/i18n.js +39 -0
  45. package/dist/components/SplitPane/index.css +153 -0
  46. package/dist/components/SplitPane/index.d.ts +3 -0
  47. package/dist/components/SplitPane/index.js +3 -0
  48. package/dist/components/WithSidePanel/README.md +19 -16
  49. package/dist/icons/index.d.ts +2 -0
  50. package/dist/icons/index.js +3 -0
  51. package/dist/index.css +17 -0
  52. package/dist/index.d.ts +3 -0
  53. package/dist/index.js +3 -0
  54. package/docs/{maybe-todo.md → _archive/maybe-todo.md} +18 -7
  55. package/docs/architecture.md +1 -1
  56. package/docs/conventions.md +27 -7
  57. package/docs/domains/actions.md +18 -18
  58. package/docs/domains/attachments.md +72 -9
  59. package/docs/domains/components.md +175 -32
  60. package/docs/domains/theming.md +57 -13
  61. package/package.json +5 -5
@@ -0,0 +1,10 @@
1
+ import { type ResizableOptions } from "./resizable.js";
2
+ import { type ResizableWidthOptions } from "../actions/resizable-width.svelte.js";
3
+ declare const Resizable: import("svelte").Component<{
4
+ options?: ResizableOptions;
5
+ action?: ResizableWidthOptions | undefined;
6
+ external?: boolean;
7
+ vertical?: boolean;
8
+ }, {}, "">;
9
+ type Resizable = ReturnType<typeof Resizable>;
10
+ export default Resizable;
@@ -0,0 +1,295 @@
1
+ import { getId } from "../utils/get-id.js";
2
+ import { localStorageState, sessionStorageState, } from "../utils/persistent-state.svelte.js";
3
+ import { twMerge } from "../utils/tw-merge.js";
4
+ const DEFAULT_LABEL = "Resize";
5
+ /**
6
+ * Makes an element's width (`axis: "x"`) or height (`axis: "y"`) resizable by dragging
7
+ * its edge. The handle is a keyboard-operable ARIA window splitter (`role="separator"`,
8
+ * focusable, `aria-valuenow` in `units`): arrow keys along the axis move it by `step`
9
+ * (×10 with Shift), Home / End go to `min` / `max`, Enter (like double-click) resets.
10
+ * Pointer Events with capture cover mouse, touch and pen in one code path.
11
+ *
12
+ * By default a thin handle strip with a grip is appended inside the element on the
13
+ * resized edge (the element becomes `position: relative`); pass `handle` to drive an
14
+ * element of your own (a sibling separator, say) instead. The size can be persisted
15
+ * under `key`. `min` / `max` clamp every path (drag, keys, `api.set()`).
16
+ *
17
+ * A factory: call it with the options (or a function returning them — reactive reads
18
+ * inside re-run the attachment) and attach the result.
19
+ *
20
+ * @example
21
+ * ```svelte
22
+ * <script>
23
+ * import { resizable } from "@marianmeres/stuic";
24
+ * </script>
25
+ *
26
+ * <!-- side by side: the first flex child gets a draggable width -->
27
+ * <div class="flex">
28
+ * <aside {@attach resizable({ initial: 300, min: 200, max: 600, key: "sidebar" })}>…</aside>
29
+ * <main class="flex-1">…</main>
30
+ * </div>
31
+ *
32
+ * <!-- stacked: a draggable height, in % of the (definite-height) parent -->
33
+ * <div class="flex flex-col h-96">
34
+ * <div {@attach resizable({ axis: "y", units: "%", initial: 40, max: 80 })}>…</div>
35
+ * <div class="flex-1">…</div>
36
+ * </div>
37
+ * ```
38
+ *
39
+ * @param options - {@link ResizableOptions}, or a function returning them
40
+ */
41
+ export function resizable(options = {}) {
42
+ return (el) => setup(el, typeof options === "function" ? options() : options);
43
+ }
44
+ // internals //////////////////////////////////////////////////////////////////////////
45
+ const HANDLE_CLS_COMMON = [
46
+ "group",
47
+ "absolute",
48
+ "bg-black/20 hover:bg-black/30",
49
+ "dark:bg-white/10 dark:hover:bg-white/20",
50
+ "transition-colors duration-200",
51
+ "touch-none",
52
+ "focus-visible:outline-2 focus-visible:outline-(--stuic-color-ring)",
53
+ ];
54
+ const HANDLE_CLS_X = [...HANDLE_CLS_COMMON, "top-0 bottom-0 w-[1px] cursor-col-resize"];
55
+ const HANDLE_CLS_Y = [...HANDLE_CLS_COMMON, "left-0 right-0 h-[1px] cursor-row-resize"];
56
+ const GRIP_CLS_COMMON = [
57
+ "absolute",
58
+ "rounded border border-black/20 dark:border-white/20",
59
+ "bg-gray-300 group-hover:bg-gray-400",
60
+ "dark:bg-gray-600 dark:group-hover:bg-gray-500",
61
+ "transition-colors duration-200",
62
+ "touch-none",
63
+ ];
64
+ const GRIP_CLS_X = [
65
+ ...GRIP_CLS_COMMON,
66
+ "h-[20px] w-[9px] -translate-x-[4px] top-1/2 -translate-y-1/2 cursor-col-resize",
67
+ ];
68
+ const GRIP_CLS_Y = [
69
+ ...GRIP_CLS_COMMON,
70
+ "w-[20px] h-[9px] -translate-y-[4px] left-1/2 -translate-x-1/2 cursor-row-resize",
71
+ ];
72
+ function create_handle(el, isX, reverse, handleClass, handleDragClass) {
73
+ const handle = document.createElement("div");
74
+ handle.setAttribute("data-handle", "true");
75
+ const grip = document.createElement("div");
76
+ grip.classList.add(...twMerge((isX ? GRIP_CLS_X : GRIP_CLS_Y).join(" "), handleDragClass).split(" "));
77
+ handle.appendChild(grip);
78
+ const edge = isX ? (reverse ? "left-0" : "right-0") : reverse ? "top-0" : "bottom-0";
79
+ handle.classList.add(...twMerge((isX ? HANDLE_CLS_X : HANDLE_CLS_Y).join(" "), edge, handleClass).split(" "));
80
+ el.appendChild(handle);
81
+ return handle;
82
+ }
83
+ function setup(el, options) {
84
+ const { enabled = true, axis = "x", initial = 0, min = 0, max = 0, units = "px", reverse = false, key, storage = "session", step = units === "%" ? 1 : 10, label = DEFAULT_LABEL, resetTo = initial, handle: providedHandle, handleClass = "", handleDragClass = "", onResize, onInit, debug, } = options;
85
+ const _debug = (...args) => debug?.("[resizable]", ...args);
86
+ _debug("setup", options);
87
+ if (!enabled)
88
+ return;
89
+ const container = el.parentElement;
90
+ if (!container)
91
+ return;
92
+ const isX = axis === "x";
93
+ const dim = isX ? "width" : "height";
94
+ // storage: a stored size wins over `initial`
95
+ const stored = key
96
+ ? (storage === "local" ? localStorageState : sessionStorageState)(`resizable-${dim}-${key}`, initial)
97
+ : null;
98
+ // handle /////////////////////////////////////////////////////////////////////////
99
+ const handle = providedHandle ?? create_handle(el, isX, reverse, handleClass, handleDragClass);
100
+ // what a provided handle looked like before, so the cleanup can restore it
101
+ const prevAttrs = new Map();
102
+ const set_attr = (name, value) => {
103
+ if (!prevAttrs.has(name))
104
+ prevAttrs.set(name, handle.getAttribute(name));
105
+ handle.setAttribute(name, value);
106
+ };
107
+ const prevPosition = el.style.position;
108
+ const prevTouchAction = handle.style.touchAction;
109
+ if (!providedHandle)
110
+ el.style.position = "relative"; // the created handle is absolute
111
+ handle.style.touchAction = "none"; // no scrolling / zooming mid-drag
112
+ const assignedId = el.id ? null : (el.id = getId("stuic-resizable-"));
113
+ set_attr("role", "separator");
114
+ set_attr("tabindex", "0");
115
+ set_attr("aria-orientation", isX ? "vertical" : "horizontal");
116
+ set_attr("aria-label", label);
117
+ set_attr("aria-controls", el.id);
118
+ // sizing /////////////////////////////////////////////////////////////////////////
119
+ const measure = () => (isX ? container.offsetWidth : container.offsetHeight);
120
+ const current_px = () => parseFloat(getComputedStyle(el)[dim]) || 0;
121
+ // px -> units; with no measurable container (hidden) a % size is left as it is
122
+ const to_units = (px, cs) => units === "%" ? (cs > 0 ? (px / cs) * 100 : current) : px;
123
+ const clamp = (value) => {
124
+ const before = value;
125
+ if (min)
126
+ value = Math.max(min, value);
127
+ if (max)
128
+ value = Math.min(max, value);
129
+ if (before !== value)
130
+ _debug("clamped", before, "->", value, units);
131
+ return value;
132
+ };
133
+ let current = 0; // last applied size, in units
134
+ function update_aria(cs) {
135
+ set_attr("aria-valuemin", `${min || 0}`);
136
+ set_attr("aria-valuemax", `${max || (units === "%" ? 100 : Math.round(cs))}`);
137
+ set_attr("aria-valuenow", `${Math.round(current)}`);
138
+ set_attr("aria-valuetext", `${Math.round(current)}${units}`);
139
+ }
140
+ function commit(size, cs) {
141
+ size = clamp(units === "%" ? Math.min(100, size) : size);
142
+ el.style[dim] = `${size}${units}`;
143
+ current = size;
144
+ _debug("applied", size, units);
145
+ update_aria(cs);
146
+ const info = { size, units, axis, container: cs };
147
+ onResize?.(info);
148
+ if (stored)
149
+ stored.current = size;
150
+ return info;
151
+ }
152
+ /** Applies a size given in `units` — needs no container measurement, so it also works while hidden. */
153
+ function apply(size) {
154
+ commit(size, measure());
155
+ }
156
+ /** Applies a size given in px (a drag), against the container as measured when it started. */
157
+ function apply_px(px, cs) {
158
+ commit(to_units(px, cs), cs);
159
+ }
160
+ function reset() {
161
+ if (resetTo)
162
+ apply(resetTo);
163
+ }
164
+ // pointer ////////////////////////////////////////////////////////////////////////
165
+ let dragging = false;
166
+ let pointerId = -1;
167
+ let startCoord = 0;
168
+ let startPx = 0;
169
+ let containerSize = 0;
170
+ function on_pointerdown(e) {
171
+ if (e.button !== 0 && e.pointerType === "mouse")
172
+ return;
173
+ dragging = true;
174
+ pointerId = e.pointerId;
175
+ startCoord = isX ? e.clientX : e.clientY;
176
+ startPx = current_px();
177
+ containerSize = measure();
178
+ try {
179
+ handle.setPointerCapture(e.pointerId);
180
+ }
181
+ catch {
182
+ /* capture is best-effort (synthetic pointers have no id to capture) */
183
+ }
184
+ document.body.style.cursor = isX ? "col-resize" : "row-resize";
185
+ document.body.style.userSelect = "none";
186
+ handle.setAttribute("data-resizing", "");
187
+ }
188
+ function on_pointermove(e) {
189
+ if (!dragging || e.pointerId !== pointerId)
190
+ return;
191
+ e.preventDefault();
192
+ const delta = (isX ? e.clientX : e.clientY) - startCoord;
193
+ apply_px(reverse ? startPx - delta : startPx + delta, containerSize);
194
+ }
195
+ function on_pointerup(e) {
196
+ if (!dragging || e.pointerId !== pointerId)
197
+ return;
198
+ stop();
199
+ }
200
+ function stop() {
201
+ if (!dragging)
202
+ return;
203
+ dragging = false;
204
+ try {
205
+ handle.releasePointerCapture(pointerId);
206
+ }
207
+ catch {
208
+ /* noop */
209
+ }
210
+ document.body.style.cursor = "";
211
+ document.body.style.userSelect = "";
212
+ handle.removeAttribute("data-resizing");
213
+ }
214
+ // keyboard ///////////////////////////////////////////////////////////////////////
215
+ function on_keydown(e) {
216
+ const amount = e.shiftKey ? step * 10 : step;
217
+ // delta along the screen direction (+ = right / down), like a pointer drag
218
+ let delta;
219
+ if (e.key === (isX ? "ArrowRight" : "ArrowDown"))
220
+ delta = amount;
221
+ else if (e.key === (isX ? "ArrowLeft" : "ArrowUp"))
222
+ delta = -amount;
223
+ else if (e.key === "Home")
224
+ delta = NaN;
225
+ else if (e.key === "End")
226
+ delta = NaN;
227
+ else if (e.key === "Enter")
228
+ delta = NaN;
229
+ else
230
+ return;
231
+ e.preventDefault();
232
+ if (e.key === "Home")
233
+ return apply(min || 0);
234
+ if (e.key === "End")
235
+ return apply(max || (units === "%" ? 100 : measure()));
236
+ if (e.key === "Enter")
237
+ return reset();
238
+ apply(current + (reverse ? -delta : delta));
239
+ }
240
+ function on_dblclick() {
241
+ reset();
242
+ }
243
+ function on_selectstart(e) {
244
+ e.preventDefault();
245
+ }
246
+ // go /////////////////////////////////////////////////////////////////////////////
247
+ const startSize = stored ? (stored.current ?? initial) : initial;
248
+ if (startSize) {
249
+ apply(startSize);
250
+ }
251
+ else {
252
+ const cs = measure();
253
+ current = to_units(current_px(), cs);
254
+ update_aria(cs);
255
+ }
256
+ handle.addEventListener("pointerdown", on_pointerdown);
257
+ handle.addEventListener("pointermove", on_pointermove);
258
+ handle.addEventListener("pointerup", on_pointerup);
259
+ handle.addEventListener("pointercancel", on_pointerup);
260
+ handle.addEventListener("keydown", on_keydown);
261
+ handle.addEventListener("dblclick", on_dblclick);
262
+ handle.addEventListener("selectstart", on_selectstart);
263
+ onInit?.({
264
+ get current() {
265
+ return current;
266
+ },
267
+ set: apply,
268
+ reset,
269
+ });
270
+ return () => {
271
+ stop();
272
+ handle.removeEventListener("pointerdown", on_pointerdown);
273
+ handle.removeEventListener("pointermove", on_pointermove);
274
+ handle.removeEventListener("pointerup", on_pointerup);
275
+ handle.removeEventListener("pointercancel", on_pointerup);
276
+ handle.removeEventListener("keydown", on_keydown);
277
+ handle.removeEventListener("dblclick", on_dblclick);
278
+ handle.removeEventListener("selectstart", on_selectstart);
279
+ if (providedHandle) {
280
+ for (const [name, value] of prevAttrs) {
281
+ if (value === null)
282
+ handle.removeAttribute(name);
283
+ else
284
+ handle.setAttribute(name, value);
285
+ }
286
+ handle.style.touchAction = prevTouchAction;
287
+ }
288
+ else {
289
+ handle.remove();
290
+ el.style.position = prevPosition;
291
+ }
292
+ if (assignedId && el.id === assignedId)
293
+ el.removeAttribute("id");
294
+ };
295
+ }
@@ -174,16 +174,17 @@ Notes:
174
174
 
175
175
  ### Component Tokens
176
176
 
177
- | Variable | Default | Description |
178
- | ------------------------------ | ---------------------- | ------------------- |
179
- | `--stuic-button-radius` | `--radius-md` | Border radius |
180
- | `--stuic-button-font-family` | `--font-sans` | Font family |
181
- | `--stuic-button-font-weight` | `--font-weight-medium` | Font weight |
182
- | `--stuic-button-transition` | `100ms` | Transition duration |
183
- | `--stuic-button-ring-width` | `3px` | Focus ring width |
184
- | `--stuic-button-ring-color` | `--stuic-color-ring` | Focus ring color |
185
- | `--stuic-button-raised-offset` | `2px` | 3D effect offset |
186
- | `--stuic-button-raised-color` | `rgb(0 0 0 / 0.8)` | 3D shadow color |
177
+ | Variable | Default | Description |
178
+ | ------------------------------ | ------------------------------ | ------------------- |
179
+ | `--stuic-button-radius` | `--stuic-radius-button` | Border radius |
180
+ | `--stuic-button-border-width` | `--stuic-border-width-button` | Border width |
181
+ | `--stuic-button-font-family` | `--font-sans` | Font family |
182
+ | `--stuic-button-font-weight` | `--font-weight-medium` | Font weight |
183
+ | `--stuic-button-transition` | `--stuic-transition` (`150ms`) | Transition duration |
184
+ | `--stuic-button-ring-width` | `3px` | Focus ring width |
185
+ | `--stuic-button-ring-color` | `--stuic-color-ring` | Focus ring color |
186
+ | `--stuic-button-raised-offset` | `2px` | 3D effect offset |
187
+ | `--stuic-button-raised-color` | `rgb(0 0 0 / 0.8)` | 3D shadow color |
187
188
 
188
189
  ### Size Tokens
189
190
 
@@ -129,34 +129,63 @@ disable the whole group instead, use the top-level `disabled` prop.
129
129
 
130
130
  ## CSS Variables
131
131
 
132
+ All tokens below are **declared** in the component's `:root`. That means a `:root` override
133
+ or a `style` prop override both work, but the `var(--token, <fallback>)` fallbacks visible in
134
+ `index.css` never fire — the defaults are the `:root` values listed here.
135
+
132
136
  ### Component Tokens
133
137
 
134
- | Variable | Default | Description |
135
- | ---------------------------------------- | ------------------------- | --------------------------------------- |
136
- | `--stuic-button-group-radius` | `var(--radius-md)` | Border radius for container and buttons |
137
- | `--stuic-button-group-padding` | `0.375rem` | Container padding |
138
- | `--stuic-button-group-gap` | `0.25rem` | Gap between buttons |
139
- | `--stuic-button-group-border-width` | `1px` | Container border width |
140
- | `--stuic-button-group-transition` | `150ms` | Transition duration |
141
- | `--stuic-button-group-ring-width` | `3px` | Focus ring width |
142
- | `--stuic-button-group-ring-color` | `var(--stuic-color-ring)` | Focus ring color |
143
- | `--stuic-button-group-button-padding-x` | `0.75rem` | Button horizontal padding |
144
- | `--stuic-button-group-button-padding-y` | `0.5rem` | Button vertical padding |
145
- | `--stuic-button-group-button-min-height` | `2.75rem` | Button min height (44px touch target) |
138
+ | Variable | Default | Description |
139
+ | ---------------------------------------- | ------------------------- | ---------------------------------------------- |
140
+ | `--stuic-button-group-radius` | `9999px` | Border radius for container and buttons (pill) |
141
+ | `--stuic-button-group-padding-x` | `4px` | Container horizontal padding |
142
+ | `--stuic-button-group-padding-y` | `3px` | Container vertical padding |
143
+ | `--stuic-button-group-gap` | `0.25rem` | Gap between buttons |
144
+ | `--stuic-button-group-border-width` | `1px` | Container border width |
145
+ | `--stuic-button-group-ring-width` | `4px` | Focus ring width |
146
+ | `--stuic-button-group-ring-color` | `var(--stuic-color-ring)` | Focus ring color |
147
+ | `--stuic-button-group-button-padding-x` | `0.75rem` | Button horizontal padding |
148
+ | `--stuic-button-group-button-padding-y` | `0.375rem` | Button vertical padding |
149
+ | `--stuic-button-group-button-min-height` | `2.25rem` | Button min height (36px) |
150
+ | `--stuic-button-group-transition` | `var(--stuic-transition)` | Transition duration (not declared — fallback) |
151
+
152
+ `--stuic-button-group-transition` is the one exception: it is _not_ declared, so it resolves
153
+ through its usage-site fallback to `--stuic-transition`.
146
154
 
147
155
  ### Color Tokens
148
156
 
149
- | Variable | Default | Description |
150
- | --------------------------------------------- | --------------------------------------- | -------------------------------- |
151
- | `--stuic-button-group-bg` | `var(--stuic-color-surface)` | Container background |
152
- | `--stuic-button-group-text` | `var(--stuic-color-foreground)` | Container text color |
153
- | `--stuic-button-group-border` | `var(--stuic-color-border)` | Container border color |
154
- | `--stuic-button-group-button-bg` | `transparent` | Inactive button background |
155
- | `--stuic-button-group-button-text` | `var(--stuic-color-foreground)` | Inactive button text |
156
- | `--stuic-button-group-button-bg-hover` | `var(--stuic-color-muted)` | Inactive button hover background |
157
- | `--stuic-button-group-button-bg-active` | `var(--stuic-color-primary)` | Active button background |
158
- | `--stuic-button-group-button-text-active` | `var(--stuic-color-primary-foreground)` | Active button text |
159
- | `--stuic-button-group-button-bg-active-hover` | `var(--stuic-color-primary-hover)` | Active button hover background |
157
+ | Variable | Default | Description |
158
+ | ----------------------------------------------- | ----------------------------------------- | -------------------------------- |
159
+ | `--stuic-button-group-bg` | `var(--stuic-color-muted)` | Container background |
160
+ | `--stuic-button-group-text` | `var(--stuic-color-muted-foreground)` | Container text color |
161
+ | `--stuic-button-group-border` | `var(--stuic-input-border)` | Container border color |
162
+ | `--stuic-button-group-border-focus` | `var(--stuic-color-primary)` | Container border on focus-within |
163
+ | `--stuic-button-group-button-bg` | `transparent` | Inactive button background |
164
+ | `--stuic-button-group-button-text` | `var(--stuic-color-muted-foreground)` | Inactive button text |
165
+ | `--stuic-button-group-button-bg-hover` | `transparent` | Inactive button hover background |
166
+ | `--stuic-button-group-button-text-hover` | `var(--stuic-color-foreground)` | Inactive button hover text |
167
+ | `--stuic-button-group-button-bg-active` | `var(--stuic-color-surface-1)` | Active button background |
168
+ | `--stuic-button-group-button-text-active` | `var(--stuic-color-surface-1-foreground)` | Active button text |
169
+ | `--stuic-button-group-button-bg-active-hover` | `var(--stuic-color-surface-1)` | Active button hover background |
170
+ | `--stuic-button-group-button-text-active-hover` | `var(--stuic-color-surface-1-foreground)` | Active button hover text |
171
+
172
+ ### Size Variants
173
+
174
+ `size` sets `data-size` on the container, which re-declares a subset of the tokens above.
175
+ `md` is the `:root` default; `sm` and `lg` override these:
176
+
177
+ | Token | `sm` | `md` (default) | `lg` |
178
+ | ---------------------------------------- | ---------- | -------------- | ---------- |
179
+ | `--stuic-button-group-padding-x` | `3px` | `4px` | `8px` |
180
+ | `--stuic-button-group-padding-y` | `2px` | `3px` | `6px` |
181
+ | `--stuic-button-group-gap` | `0.125rem` | `0.25rem` | `0.375rem` |
182
+ | `--stuic-button-group-button-padding-x` | `0.5rem` | `0.75rem` | `1rem` |
183
+ | `--stuic-button-group-button-padding-y` | `0.375rem` | `0.375rem` | `0.625rem` |
184
+ | `--stuic-button-group-button-min-height` | `2.25rem` | `2.25rem` | `3rem` |
185
+ | overall height | 42px | 44px | 62px |
186
+
187
+ Because the size variants declare these on the container itself, they beat the inherited
188
+ `:root` values — but an inline `style` on the same element still wins over both.
160
189
 
161
190
  ### Customization Examples
162
191
 
@@ -78,7 +78,9 @@
78
78
  ============================================================================ */
79
79
 
80
80
  .stuic-button-group[data-size="sm"] {
81
- --stuic-button-group-padding: 0.25rem;
81
+ /* overall height = 36px + 2×2px + 2×1px = 42px */
82
+ --stuic-button-group-padding-x: 3px;
83
+ --stuic-button-group-padding-y: 2px;
82
84
  --stuic-button-group-gap: 0.125rem;
83
85
  --stuic-button-group-button-padding-x: 0.5rem;
84
86
  --stuic-button-group-button-padding-y: 0.375rem;
@@ -86,7 +88,9 @@
86
88
  }
87
89
 
88
90
  .stuic-button-group[data-size="lg"] {
89
- --stuic-button-group-padding: 0.5rem;
91
+ /* overall height = 48px + 2×6px + 2×1px = 62px */
92
+ --stuic-button-group-padding-x: 8px;
93
+ --stuic-button-group-padding-y: 6px;
90
94
  --stuic-button-group-gap: 0.375rem;
91
95
  --stuic-button-group-button-padding-x: 1rem;
92
96
  --stuic-button-group-button-padding-y: 0.625rem;
@@ -177,6 +177,11 @@
177
177
  let _classTiers = $derived(
178
178
  unstyled ? classTiersProp : twMerge("stuic-pricing-table-tiers", classTiersProp)
179
179
  );
180
+ // The toggle's pill look lives in index.css behind --stuic-pricing-table-toggle-*
181
+ // tokens (an inline declaration here would outrank any :root override).
182
+ let _classToggle = $derived(
183
+ unstyled ? classToggleProp : twMerge("stuic-pricing-table-toggle", classToggleProp)
184
+ );
180
185
  </script>
181
186
 
182
187
  <div bind:this={el} class={_class} {style} {...rest}>
@@ -195,20 +200,9 @@
195
200
  onButtonClick={(i) => {
196
201
  billingPeriod = i === 0 ? "monthly" : "annual";
197
202
  }}
198
- class={classToggleProp}
199
- style="
200
- width: auto;
201
- --stuic-button-group-radius: 9999px;
202
- --stuic-button-group-padding: 0.25rem;
203
- --stuic-button-group-bg: var(--stuic-color-muted);
204
- --stuic-button-group-border-width: 0;
205
- --stuic-button-group-button-bg-active: var(--stuic-color-background);
206
- --stuic-button-group-button-text-active: var(--stuic-color-foreground);
207
- --stuic-button-group-button-bg-active-hover: var(--stuic-color-background);
208
- --stuic-button-group-button-text-active-hover: var(--stuic-color-foreground);
209
- {styleToggle ?? ''}
210
- "
211
- classButtonActive="shadow"
203
+ class={_classToggle}
204
+ style={styleToggle}
205
+ classButtonActive={unstyled ? undefined : "shadow"}
212
206
  />
213
207
  {/if}
214
208
  {/if}
@@ -161,12 +161,35 @@ A data-driven pricing tiers component for displaying plan comparisons with featu
161
161
 
162
162
  ### Toggle
163
163
 
164
- | Variable | Default | Description |
165
- | ------------------------------------------- | ---------------- | ------------------------ |
166
- | `--stuic-pricing-table-toggle-bg` | muted | Toggle track background |
167
- | `--stuic-pricing-table-toggle-bg-active` | background | Active option background |
168
- | `--stuic-pricing-table-toggle-color` | muted-foreground | Option text color |
169
- | `--stuic-pricing-table-toggle-color-active` | foreground | Active option text color |
164
+ The billing toggle is an internal `ButtonGroupRadio`. Its pill look is expressed through the
165
+ tokens below, so a theme can reach it from `:root` like any other part of the component:
166
+
167
+ ```css
168
+ :root {
169
+ /* square toggle to match a flat theme */
170
+ --stuic-pricing-table-toggle-radius: var(--stuic-radius-button);
171
+ }
172
+ ```
173
+
174
+ | Variable | Default | Description |
175
+ | ------------------------------------------------- | ---------------- | ------------------------------------------- |
176
+ | `--stuic-pricing-table-toggle-radius` | `9999px` | Track and option border radius |
177
+ | `--stuic-pricing-table-toggle-bg` | muted | Toggle track background |
178
+ | `--stuic-pricing-table-toggle-border-width` | `0` | Toggle track border width |
179
+ | `--stuic-pricing-table-toggle-padding-x` | `0.25rem` | Track horizontal padding around the options |
180
+ | `--stuic-pricing-table-toggle-padding-y` | `0.25rem` | Track vertical padding around the options |
181
+ | `--stuic-pricing-table-toggle-color` | muted-foreground | Inactive option text color |
182
+ | `--stuic-pricing-table-toggle-color-hover` | foreground | Inactive option text color on hover |
183
+ | `--stuic-pricing-table-toggle-bg-active` | background | Active option background |
184
+ | `--stuic-pricing-table-toggle-color-active` | foreground | Active option text color |
185
+ | `--stuic-pricing-table-toggle-bg-active-hover` | `-bg-active` | Active option background on hover |
186
+ | `--stuic-pricing-table-toggle-color-active-hover` | `-color-active` | Active option text color on hover |
187
+
188
+ For anything these do not cover, `styleToggle` sets inline styles on the group (including raw
189
+ `--stuic-button-group-*` tokens) and wins over all of the above; `classToggle` adds classes.
190
+ Note that `--stuic-button-group-*` set at `:root` does **not** reach the pricing toggle for the
191
+ properties listed above — the component declares them on the element, so use the
192
+ `--stuic-pricing-table-toggle-*` tokens instead.
170
193
 
171
194
  ### Typography
172
195
 
@@ -17,6 +17,60 @@
17
17
  width: 100%;
18
18
  }
19
19
 
20
+ /* ============================================================================
21
+ BILLING TOGGLE (an internal ButtonGroupRadio)
22
+
23
+ The opinionated pill look lives here rather than in the component's inline
24
+ `style` attribute, because an inline custom property declaration outranks any
25
+ `:root` declaration for that element and its subtree -- which made the toggle
26
+ the one part of this component a theme could not reach. Each value is a
27
+ never-declared `--stuic-pricing-table-toggle-*` component token, so
28
+ `:root { --stuic-pricing-table-toggle-radius: 0; }` works like every other
29
+ token here. `styleToggle` still wins over all of it (inline).
30
+ ============================================================================ */
31
+
32
+ .stuic-pricing-table-toggle {
33
+ /* hug the options instead of spanning the row (the group defaults to 100%) */
34
+ width: auto;
35
+
36
+ --stuic-button-group-radius: var(--stuic-pricing-table-toggle-radius, 9999px);
37
+ --stuic-button-group-padding-x: var(--stuic-pricing-table-toggle-padding-x, 0.25rem);
38
+ --stuic-button-group-padding-y: var(--stuic-pricing-table-toggle-padding-y, 0.25rem);
39
+ --stuic-button-group-bg: var(
40
+ --stuic-pricing-table-toggle-bg,
41
+ var(--stuic-color-muted)
42
+ );
43
+ --stuic-button-group-border-width: var(--stuic-pricing-table-toggle-border-width, 0);
44
+
45
+ /* Inactive option */
46
+ --stuic-button-group-button-text: var(
47
+ --stuic-pricing-table-toggle-color,
48
+ var(--stuic-color-muted-foreground)
49
+ );
50
+ --stuic-button-group-button-text-hover: var(
51
+ --stuic-pricing-table-toggle-color-hover,
52
+ var(--stuic-color-foreground)
53
+ );
54
+
55
+ /* Active option (hover falls back to the resting value = no hover shift) */
56
+ --stuic-button-group-button-bg-active: var(
57
+ --stuic-pricing-table-toggle-bg-active,
58
+ var(--stuic-color-background)
59
+ );
60
+ --stuic-button-group-button-text-active: var(
61
+ --stuic-pricing-table-toggle-color-active,
62
+ var(--stuic-color-foreground)
63
+ );
64
+ --stuic-button-group-button-bg-active-hover: var(
65
+ --stuic-pricing-table-toggle-bg-active-hover,
66
+ var(--stuic-pricing-table-toggle-bg-active, var(--stuic-color-background))
67
+ );
68
+ --stuic-button-group-button-text-active-hover: var(
69
+ --stuic-pricing-table-toggle-color-active-hover,
70
+ var(--stuic-pricing-table-toggle-color-active, var(--stuic-color-foreground))
71
+ );
72
+ }
73
+
20
74
  /* ============================================================================
21
75
  TIERS GRID
22
76
  ============================================================================ */