@delightstack/components 0.1.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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,1450 @@
1
+ <script lang="ts">
2
+ import { ripple, tooltip } from '@delightstack/utilities';
3
+ import { getContext, type Snippet } from 'svelte';
4
+ import { fade, type TransitionConfig } from 'svelte/transition';
5
+ import { backOut, quartOut } from 'svelte/easing';
6
+ import Popover, { type PopoverPlacement, type PopoverStrategy } from './Popover.svelte';
7
+ import Progress from '../feedback/Progress.svelte';
8
+ import type { ButtonGroupContext } from './ButtonGroup.svelte';
9
+
10
+ const groupContext = getContext<ButtonGroupContext | undefined>('button-group');
11
+
12
+ // When a Button with `type="submit"` lives inside a <Form>, auto-wire its
13
+ // loading and disabled state to the form's submission lifecycle. Callers
14
+ // can still override by passing explicit `loading` or `disabled`.
15
+ type _FormSubmitContext = { is_submitting: boolean; disabled: boolean };
16
+ const formContext = getContext<_FormSubmitContext | undefined>('form');
17
+
18
+ const propId = $props.id();
19
+ let {
20
+ /** The size of the button - referencing the font size options in css vars*/
21
+ size = undefined as
22
+ | undefined
23
+ | '0000'
24
+ | '000'
25
+ | '00'
26
+ | '0'
27
+ | '1'
28
+ | '2'
29
+ | '3'
30
+ | '4'
31
+ | '5'
32
+ | '6',
33
+
34
+ /** Whether the button is an icon button only */
35
+ icon = false,
36
+
37
+ /** Whether the button is a pill shape (rounded corners) */
38
+ pill = false,
39
+
40
+ /** Whether the button has a transparent background */
41
+ transparent = false,
42
+
43
+ /** Whether the button has a semi-transparent background (takes on some of the color of the text color) */
44
+ translucent = false,
45
+
46
+ /** Whether the button has an outline style (transparent background with border) */
47
+ outline = false,
48
+
49
+ /**
50
+ * Whether the button is part of a group of buttons.
51
+ * If so, the border radius will be removed on the sides that touch other buttons
52
+ * and the borders/margins will be merged
53
+ */
54
+ grouped = false,
55
+
56
+ /**
57
+ * Whether the will be styled to indicator an error (or danger).
58
+ * If 'transparent', this makes the text red instead of the background
59
+ * If not 'transparent', this makes the background red
60
+ */
61
+ error = false,
62
+
63
+ /**
64
+ * Whether the will be styled to indicator sucess
65
+ * If 'transparent', this makes the text green instead of the background
66
+ * If not 'transparent', this makes the background green
67
+ */
68
+ success = false,
69
+
70
+ /** Whether the button is an 'overlay' - blurs & darkens the background */
71
+ overlay = false,
72
+
73
+ /**
74
+ * Whether the background color should be the accent/primary/brand color.
75
+ * If 'transparent' is also true, this will change the color of the text instead
76
+ */
77
+ accent = false,
78
+
79
+ /** Whether the button should be smaller (with less padding) */
80
+ dense = false,
81
+
82
+ /** Whether the button should be larger (with more padding) */
83
+ comfortable = false,
84
+
85
+ /** Whether the button should take up the full width of its container */
86
+ full_width = false,
87
+
88
+ /** Whether the button should take up the full height of its container */
89
+ full_height = false,
90
+
91
+ /** Whether there should not be a ripple animation on click */
92
+ disable_ripple = false,
93
+
94
+ /** The url to link to (turns the button into an anchor tag) */
95
+ href = undefined as string | undefined,
96
+
97
+ /** The target of the link (only used if href is provided) */
98
+ target = undefined as '_self' | '_blank' | '_parent' | '_top' | undefined,
99
+
100
+ /** The button type (ignored when `href` is set). @default 'button' */
101
+ type = 'button' as 'button' | 'submit' | 'reset',
102
+
103
+ /** The tooltip message to show on hover */
104
+ tooltip: tooltip_message = '',
105
+
106
+ /** Whether the button is disabled */
107
+ disabled = false,
108
+
109
+ /** Whether the button is in in the 'active' state (similar to how a toggle button would be 'selected') */
110
+ active = false,
111
+
112
+ /**
113
+ * Whether a loading spinner should appear before the button text.
114
+ * Leave undefined to let a promise-returning `onclick` drive it
115
+ * automatically (see `onclick`).
116
+ */
117
+ loading = undefined as boolean | undefined,
118
+
119
+ /**
120
+ * For the manual `loading` path only: when `loading` goes true -> false
121
+ * and this is true, a success checkmark briefly animates in to confirm the
122
+ * action, then animates away. (The promise-aware `onclick` path shows this
123
+ * checkmark automatically on resolve, so this prop isn't needed there.)
124
+ */
125
+ loading_success = false,
126
+
127
+ /**
128
+ * The text to show in a badge hovering over the top right corner of the button
129
+ * If "true", then a small dot will appear instead of a box with text
130
+ */
131
+ badge = undefined as string | undefined | boolean,
132
+
133
+ /** The content to show in a dropdown menu when the button is clicked */
134
+ menu = undefined as undefined | Snippet<[{ close: () => void }]>,
135
+
136
+ /** Whether the button should have a chevron icon next to it (useful when used with the 'menu' optionk) */
137
+ show_chevron = false,
138
+
139
+ /** The content shown in a dropdown menu when the secondary dropdown button is clicked */
140
+ dropdown = undefined as undefined | Snippet<[{ close: () => void }]>,
141
+
142
+ /** The content shown when the button is in the loading state */
143
+ loading_content = undefined as undefined | Snippet<[{ close: () => void }]>,
144
+
145
+ /** Whether the dropdown menu should be disabled (and the secondary downdown button hidden) */
146
+ disable_dropdown = false,
147
+
148
+ /** Whether the dropdown menu should close when the user clicks a button like element inside of it */
149
+ popover_close_on_inside_click = false,
150
+
151
+ /** The placement of the popover (used when either "menu" or "dropdown" is provided) */
152
+ popover_placement = 'bottom-end' as PopoverPlacement,
153
+
154
+ /** The placement of the popover (used when either "menu" or "dropdown" is provided) */
155
+ popover_strategy = 'fixed' as PopoverStrategy,
156
+
157
+ /** Whether the intial focus should not be set automatically when opening the popover */
158
+ popover_disable_initial_focus = false,
159
+
160
+ /** The content shown in the button element */
161
+ children = undefined as
162
+ | undefined
163
+ | Snippet<[{ isLoading: boolean; isLoadingSuccess: boolean }]>,
164
+
165
+ /** The ID of the element. @defaults to a random ID */
166
+ id = propId,
167
+
168
+ /** A reference to the button element */
169
+ button_element = $bindable() as HTMLElement | undefined,
170
+
171
+ /** Specifies a custom class name for the container element */
172
+ class: class_name = '',
173
+
174
+ /** The css style string added to the component from the parent */
175
+ style = '',
176
+
177
+ /**
178
+ * The function to call when the button is clicked.
179
+ * If it returns a promise, the button manages its own loading feedback:
180
+ * - A spinner appears only if the promise is still pending after ~100ms
181
+ * (faster resolves are treated as instant — no spinner flash).
182
+ * - Once shown, the spinner stays for at least ~1s so it can't blink away.
183
+ * - On resolve, a brief success checkmark confirms the action; on reject,
184
+ * no checkmark is shown.
185
+ */
186
+ onclick = undefined as
187
+ | undefined
188
+ | ((e: MouseEvent) => void)
189
+ | ((e: MouseEvent) => Promise<void>),
190
+
191
+ ...rest
192
+ } = $props();
193
+
194
+ // Merge ButtonGroup context with local props (local props take precedence when explicitly set)
195
+ const resolvedSize = $derived(size ?? groupContext?.size);
196
+ const resolvedOutline = $derived(outline || groupContext?.outline || false);
197
+ const resolvedTransparent = $derived(transparent || groupContext?.transparent || false);
198
+ const resolvedTranslucent = $derived(translucent || groupContext?.translucent || false);
199
+ const resolvedAccent = $derived(accent || groupContext?.accent || false);
200
+ const resolvedError = $derived(error || groupContext?.error || false);
201
+ const resolvedSuccess = $derived(success || groupContext?.success || false);
202
+ // A submit button inside a <Form> inherits the form's submitting/disabled
203
+ // state unless the caller explicitly set `loading` / `disabled`.
204
+ const isFormSubmit = $derived(type === 'submit' && !!formContext);
205
+ const resolvedDisabled = $derived(
206
+ disabled ||
207
+ groupContext?.disabled ||
208
+ (isFormSubmit && formContext!.disabled) ||
209
+ false,
210
+ );
211
+ const resolvedGrouped = $derived(grouped || !!groupContext);
212
+
213
+ let dropdownActive = $state(false);
214
+ let dropdownTrigger = $state(undefined as undefined | HTMLElement);
215
+ let menuActive = $state(false);
216
+ let mounted = $state(false);
217
+ $effect(() => {
218
+ mounted = true;
219
+ });
220
+
221
+ /* Promise-aware loading timing.
222
+ - SHOW_DELAY: a promise that settles faster than this never gets a
223
+ spinner — the action reads as "instant".
224
+ - MIN_VISIBLE: once the spinner *is* shown it stays at least this long, so
225
+ it can't flash on then immediately off.
226
+ - SPINNER_OUT: how long the spinner takes to collapse away. Kept in sync
227
+ with loadingTransition's "out" duration so the success check can slot in
228
+ right after the spinner clears.
229
+ - CHECK_HOLD: how long the success checkmark lingers before easing out. */
230
+ const SHOW_DELAY = 100;
231
+ const MIN_VISIBLE = 1000;
232
+ const SPINNER_OUT = 150;
233
+ const CHECK_HOLD = 1000;
234
+
235
+ let inFlight = $state(false); // a returned promise is running (covers the pre-spinner window)
236
+ let spinnerVisible = $state(false); // the spinner is actually rendered
237
+ let checkVisible = $state(false); // the success checkmark is rendered
238
+
239
+ let showTimer: ReturnType<typeof setTimeout> | undefined;
240
+ let hideTimer: ReturnType<typeof setTimeout> | undefined;
241
+ let checkTimer: ReturnType<typeof setTimeout> | undefined;
242
+ let spinnerShownAt = 0;
243
+
244
+ function clearTimers() {
245
+ clearTimeout(showTimer);
246
+ clearTimeout(hideTimer);
247
+ clearTimeout(checkTimer);
248
+ showTimer = hideTimer = checkTimer = undefined;
249
+ }
250
+ $effect(() => clearTimers); // tear down pending timers on destroy
251
+
252
+ // `loading` prop wins if provided; otherwise a submit button tracks the
253
+ // surrounding form's is_submitting flag.
254
+ const externalLoading = $derived(
255
+ loading ?? (isFormSubmit ? formContext!.is_submitting : undefined),
256
+ );
257
+ // "Busy" for a11y/styling: an external loading flag, or a returned promise
258
+ // that's in flight (including the brief pre-spinner window and the spinner's
259
+ // minimum-visible tail). `showSpinner` is what actually renders the spinner —
260
+ // it excludes the pre-spinner window so a sub-SHOW_DELAY promise never flashes
261
+ // one. `isLoadingSuccess` reflects the post-success checkmark.
262
+ const isLoading = $derived(!!externalLoading || inFlight);
263
+ const showSpinner = $derived(!!externalLoading || spinnerVisible);
264
+ const isLoadingSuccess = $derived(checkVisible);
265
+
266
+ // Manual loading path: when the caller drives `loading` true -> false and has
267
+ // opted in with `loading_success`, play the same confirming checkmark the
268
+ // promise path shows on success. Runs in `$effect.pre` so `checkVisible` is set
269
+ // in the same flush `loading` clears in — otherwise the icon slot would render
270
+ // one empty frame (spinner gone, check not yet set) and flash closed/open.
271
+ let wasExternalLoading = false;
272
+ $effect.pre(() => {
273
+ const now = !!externalLoading;
274
+ if (wasExternalLoading && !now && loading_success && !inFlight) flashCheck();
275
+ wasExternalLoading = now;
276
+ });
277
+
278
+ // The icon slot (the common parent of the spinner and the success check)
279
+ // grows/collapses with this width+opacity transition. Because it wraps both,
280
+ // the slot only animates open when one of them first appears and only
281
+ // collapses once both are gone — the spinner -> check handoff happens inside a
282
+ // stable, already-open slot (see `checkIn` for the check's own entrance).
283
+ //
284
+ // The slot's resting layout includes negative margins (they tuck the spinner
285
+ // in close to the label/edge) and the button's flex gap. Animating width
286
+ // alone would leave those at full strength, so at t=0 the slot's total
287
+ // layout contribution would be *negative* — the label gets pulled past its
288
+ // resting position and then snaps back the moment the node is removed. So
289
+ // the margins ride `t` too, with the gap folded into margin-right's final
290
+ // frame, making the total contribution hit exactly 0 at t=0: no snap on
291
+ // insert or removal.
292
+ function loadingTransition(
293
+ node: HTMLElement,
294
+ params?: { direction?: 'in' | 'out' },
295
+ ): () => TransitionConfig {
296
+ return () => {
297
+ const style = getComputedStyle(node);
298
+ const width = parseFloat(style.width);
299
+ const marginLeft = parseFloat(style.marginLeft) || 0;
300
+ const marginRight = parseFloat(style.marginRight) || 0;
301
+ const gap = node.parentElement
302
+ ? parseFloat(getComputedStyle(node.parentElement).columnGap) || 0
303
+ : 0;
304
+ const out = params?.direction === 'out';
305
+ return {
306
+ duration: out ? SPINNER_OUT : 320,
307
+ easing: out ? quartOut : backOut,
308
+ css: (t: number) =>
309
+ `width: ${t * width}px; ` +
310
+ `margin-left: ${t * marginLeft}px; ` +
311
+ `margin-right: ${t * marginRight - (1 - t) * gap}px; ` +
312
+ `opacity: ${t};`,
313
+ };
314
+ };
315
+ }
316
+
317
+ // The checkmark's own entrance: a spring-scaled pop that simultaneously draws
318
+ // its stroke on (the dash offset rides `t`, so the tick paints itself in).
319
+ // Driving the draw from the transition — rather than a CSS @keyframes — keeps
320
+ // it reliable regardless of scoping. prefers-reduced-motion collapses it to a
321
+ // plain appear.
322
+ function checkIn(_node: Element): TransitionConfig {
323
+ const reduce =
324
+ typeof matchMedia !== 'undefined' &&
325
+ matchMedia('(prefers-reduced-motion: reduce)').matches;
326
+ return {
327
+ duration: reduce ? 0 : 440,
328
+ easing: backOut,
329
+ css: (t: number) =>
330
+ `transform: scale(${0.3 + 0.7 * t}); opacity: ${Math.min(1, t * 2)}; --check-draw: ${24 * (1 - t)};`,
331
+ };
332
+ }
333
+
334
+ // Pop the confirming checkmark, then retire it after CHECK_HOLD. spinnerVisible
335
+ // is cleared in the same tick by the caller, so the parent slot stays open and
336
+ // the spinner crossfades into the check rather than the slot reopening.
337
+ function flashCheck() {
338
+ clearTimeout(checkTimer);
339
+ checkVisible = true;
340
+ checkTimer = setTimeout(() => (checkVisible = false), CHECK_HOLD);
341
+ }
342
+
343
+ function handleClick(e: MouseEvent) {
344
+ if (inFlight || externalLoading) return;
345
+ if (!onclick) return;
346
+ const maybePromise = onclick(e);
347
+ if (!(maybePromise instanceof Promise)) return;
348
+
349
+ // A fresh action supersedes any checkmark still lingering from the last one.
350
+ clearTimers();
351
+ checkVisible = false;
352
+
353
+ inFlight = true;
354
+ // Hold off on the spinner — if the promise settles within SHOW_DELAY the
355
+ // action was effectively instant and never needs one.
356
+ showTimer = setTimeout(() => {
357
+ showTimer = undefined;
358
+ spinnerVisible = true;
359
+ spinnerShownAt = performance.now();
360
+ }, SHOW_DELAY);
361
+
362
+ maybePromise.then(
363
+ () => settle(true),
364
+ () => settle(false),
365
+ );
366
+ }
367
+
368
+ function settle(success: boolean) {
369
+ // Settled before the spinner ever appeared -> treat as instant: no
370
+ // spinner, no checkmark, just release.
371
+ if (showTimer) {
372
+ clearTimeout(showTimer);
373
+ showTimer = undefined;
374
+ inFlight = false;
375
+ return;
376
+ }
377
+ // The spinner is up; keep it for the rest of its minimum-visible window so
378
+ // it doesn't blink away the instant the promise resolves (which otherwise
379
+ // reads as a glitch when the page updates a beat later).
380
+ const remaining = Math.max(0, MIN_VISIBLE - (performance.now() - spinnerShownAt));
381
+ clearTimeout(hideTimer);
382
+ hideTimer = setTimeout(() => {
383
+ spinnerVisible = false;
384
+ inFlight = false;
385
+ // On success, let the spinner collapse, then pop a brief checkmark.
386
+ if (success) flashCheck();
387
+ }, remaining);
388
+ }
389
+
390
+ function closeMenu() {
391
+ if (menuActive) menuActive = false;
392
+ if (dropdownActive) dropdownActive = false;
393
+ }
394
+ </script>
395
+
396
+ <div
397
+ {id}
398
+ class={['button', class_name].filter(Boolean).join(' ')}
399
+ class:has-dropdown-trigger={dropdown && !disable_dropdown}
400
+ class:icon
401
+ class:pill
402
+ class:dense
403
+ class:comfortable
404
+ class:grouped={resolvedGrouped}
405
+ class:group-h={resolvedGrouped &&
406
+ groupContext?.attached &&
407
+ groupContext?.orientation === 'horizontal'}
408
+ class:group-v={resolvedGrouped &&
409
+ groupContext?.attached &&
410
+ groupContext?.orientation === 'vertical'}
411
+ class:full-width={full_width}
412
+ class:full-height={full_height}
413
+ class:overlay
414
+ class:transparent={resolvedTransparent}
415
+ class:translucent={resolvedTranslucent}
416
+ class:outline={resolvedOutline}
417
+ class:success={resolvedSuccess}
418
+ class:accent={resolvedAccent}
419
+ class:active
420
+ class:error={resolvedError}
421
+ class:is-loading={isLoading}
422
+ {style}
423
+ style:font-size={resolvedSize === undefined ? null : `var(--font-size-${resolvedSize})`}
424
+ {@attach tooltip(tooltip_message)}>
425
+ {#if badge}
426
+ <div class="badge" class:dot={badge === true}>
427
+ {#if badge !== true}{badge}{/if}
428
+ </div>
429
+ {/if}
430
+ <svelte:element
431
+ this={href ? 'a' : 'button'}
432
+ type={href ? null : type}
433
+ role="button"
434
+ tabindex={resolvedDisabled || isLoading || (!mounted && !href) ? -1 : 0}
435
+ {...rest}
436
+ {target}
437
+ {href}
438
+ data-sveltekit-noscroll={href?.startsWith('?') ? true : null}
439
+ data-sveltekit-keepfocus={href?.startsWith('?') ? true : null}
440
+ {@attach ripple({
441
+ enabled: !disable_ripple && !resolvedDisabled && !isLoading,
442
+ zIndex: 1,
443
+ })}
444
+ disabled={resolvedDisabled || inFlight || (!mounted && !href)}
445
+ aria-busy={isLoading ? 'true' : null}
446
+ aria-haspopup={!!menu}
447
+ aria-expanded={menu ? menuActive : null}
448
+ bind:this={button_element}
449
+ onclick={handleClick}>
450
+ <!-- The transition divs must sit DIRECTLY inside the {#if} that toggles
451
+ with the loading state: transitions are local by default, so nesting
452
+ them one block deeper (e.g. an inner {#if icon}) means they never
453
+ play when this block mounts/unmounts. -->
454
+ {#if icon && (showSpinner || checkVisible)}
455
+ <!-- Icon mode has no label to sit beside, so the feedback can't use
456
+ the width-growing slot. Instead it overlays the square button
457
+ while the icon itself scales away beneath it (see &.icon CSS). -->
458
+ <div class="loading-icon" transition:fade={{ duration: 150 }}>
459
+ {@render loadingLayers()}
460
+ </div>
461
+ {:else if !icon && (showSpinner || checkVisible)}
462
+ <div
463
+ class="loading-icon"
464
+ in:loadingTransition={{ direction: 'in' }}
465
+ out:loadingTransition={{ direction: 'out' }}>
466
+ {@render loadingLayers()}
467
+ </div>
468
+ {/if}
469
+ {#if children}{@render children({ isLoading, isLoadingSuccess })}{/if}
470
+ {#if show_chevron && menu}
471
+ <svg
472
+ viewBox="0 0 24 24"
473
+ fill="currentColor"
474
+ style="pointer-events:none;"
475
+ class="chevron {menuActive ? 'active' : ''}"
476
+ aria-hidden="true">
477
+ <path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
478
+ </svg>
479
+ {/if}
480
+ </svelte:element>
481
+ {#if dropdown && !disable_dropdown}
482
+ <button
483
+ class="dropdown-trigger"
484
+ type="button"
485
+ aria-haspopup="true"
486
+ aria-expanded={dropdownActive}
487
+ aria-label="Toggle dropdown"
488
+ title="Open for more actions"
489
+ {@attach ripple({
490
+ enabled: !disable_ripple && !resolvedDisabled && !isLoading,
491
+ zIndex: 1,
492
+ })}
493
+ bind:this={dropdownTrigger}>
494
+ <svg
495
+ viewBox="0 0 24 24"
496
+ fill="currentColor"
497
+ style="pointer-events:none"
498
+ class="chevron {dropdownActive ? 'active' : ''}"
499
+ aria-hidden="true">
500
+ <path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" />
501
+ </svg>
502
+ </button>
503
+ {/if}
504
+ </div>
505
+ {#if menu}
506
+ <Popover
507
+ ref_element={button_element}
508
+ bind:opened={menuActive}
509
+ open_on_click
510
+ arrow={false}
511
+ strategy={popover_strategy}
512
+ close_on_inside_click={popover_close_on_inside_click}
513
+ disable_initial_focus={popover_disable_initial_focus}
514
+ placement={popover_placement}
515
+ radius="calc(var(--radius-lg, 10px) * 1.5)">
516
+ {@render menu({ close: closeMenu })}
517
+ </Popover>
518
+ {/if}
519
+ {#if dropdown && !disable_dropdown}
520
+ <Popover
521
+ ref_element={dropdownTrigger}
522
+ bind:opened={dropdownActive}
523
+ open_on_click
524
+ arrow={false}
525
+ strategy={popover_strategy}
526
+ close_on_inside_click={popover_close_on_inside_click}
527
+ disable_initial_focus={popover_disable_initial_focus}
528
+ placement={popover_placement}
529
+ radius="calc(var(--radius-lg, 10px) * 1.5)">
530
+ {@render dropdown({ close: closeMenu })}
531
+ </Popover>
532
+ {/if}
533
+
534
+ {#snippet loadingLayers()}
535
+ {#if showSpinner}
536
+ <div class="icon-layer" out:fade={{ duration: 120 }}>
537
+ <Progress size="00" color="currentColor" />
538
+ </div>
539
+ {:else}
540
+ <div class="icon-layer check-layer" in:checkIn>
541
+ <svg
542
+ class="check"
543
+ viewBox="2 2 20 20"
544
+ fill="none"
545
+ stroke="currentColor"
546
+ stroke-width="3"
547
+ stroke-linecap="round"
548
+ stroke-linejoin="round"
549
+ aria-hidden="true">
550
+ <path d="M5 12.5l4.5 4.5L19 7" />
551
+ </svg>
552
+ </div>
553
+ {/if}
554
+ {/snippet}
555
+
556
+ <style>
557
+ .button {
558
+ --_radius: var(--action-radius, var(--radius-lg));
559
+ /* Squircle (superellipse) corners where supported. corner-shape can't form a
560
+ pill, so .pill and the icon-circle opt back out (round + no doubling) below.
561
+ corner-shape and the radius doubling are applied per surface inside @supports,
562
+ so unsupported browsers keep the plain radius. */
563
+ --_corner-shape: squircle;
564
+ --_corner-scale: var(--squircle-ratio, 2);
565
+ --easing: var(--ease-spring);
566
+ /* Default font for an unsized button. Combined with the shared control
567
+ height below, a bare <Button> matches a default Input/Select height in
568
+ a row. An explicit `size` (inline font-size) or an in-field font
569
+ override (e.g. .input-icon-btn) wins over this. */
570
+ font-size: var(--control-font-1, 1rem);
571
+ display: inline-flex;
572
+ justify-content: center;
573
+ position: relative;
574
+ width: fit-content;
575
+ border-radius: var(--_radius);
576
+ @supports (corner-shape: squircle) {
577
+ corner-shape: var(--_corner-shape);
578
+ border-radius: calc(var(--_radius) * var(--_corner-scale));
579
+ }
580
+ perspective: 100px;
581
+
582
+ &.pill {
583
+ --_radius: var(--radius-full);
584
+ --_corner-shape: round;
585
+ --_corner-scale: 1;
586
+ }
587
+
588
+ &:not(.transparent):not(.translucent) {
589
+ --color-bg: var(--color-action);
590
+ --color-bg-disabled: var(--color-action-disabled);
591
+ --color-bg-active: var(--color-action-active);
592
+ --color-text: var(--color-action-text);
593
+ --color-text-disabled: var(--color-action-text-disabled);
594
+ --color-text-active: var(--color-action-text-active);
595
+ --color-border: var(--button-border);
596
+ --color-border-disabled: var(--button-border-disabled);
597
+ --color-border-active: var(--button-border-active);
598
+ }
599
+ &.accent:not(.transparent):not(.translucent) {
600
+ --color-bg: var(--color-accent);
601
+ --color-bg-disabled: var(--color-accent-disabled);
602
+ --color-bg-active: var(--color-accent-active);
603
+ --color-text: var(--color-accent-text);
604
+ --color-text-active: var(--color-accent-text-active);
605
+ --color-text-disabled: var(--color-accent-text-disabled);
606
+ }
607
+ &.error:not(.transparent):not(.translucent) {
608
+ --color-bg: var(--color-error);
609
+ --color-bg-disabled: var(--color-error-disabled);
610
+ --color-bg-active: var(--color-error-active);
611
+ --color-text: var(--color-error-text);
612
+ --color-text-active: var(--color-error-text-active);
613
+ --color-text-disabled: var(--color-error-text-disabled);
614
+ }
615
+ &.success:not(.transparent):not(.translucent) {
616
+ --color-bg: var(--color-success);
617
+ --color-bg-disabled: var(--color-success-disabled);
618
+ --color-bg-active: var(--color-success-active);
619
+ --color-text: var(--color-success-text);
620
+ --color-text-active: var(--color-success-text-active);
621
+ --color-text-disabled: var(--color-success-text-disabled);
622
+ }
623
+
624
+ &.outline:not(.transparent):not(.translucent) {
625
+ --color-bg: transparent;
626
+ --color-bg-disabled: transparent;
627
+ --color-bg-active: rgb(from var(--color-action) r g b / 0.08);
628
+ --button-border: 1px solid currentColor;
629
+ --button-border-disabled: 1px solid currentColor;
630
+ --button-border-active: 1px solid currentColor;
631
+ --color-text: light-dark(
632
+ oklch(from var(--color-action) min(l, 0.5) c h),
633
+ oklch(from var(--color-action) max(l, 0.65) c h)
634
+ );
635
+ --color-text-disabled: light-dark(
636
+ oklch(from var(--color-action) min(l + 0.1, 0.6) calc(c - 0.05) h),
637
+ oklch(from var(--color-action) max(l - 0.1, 0.55) calc(c - 0.05) h)
638
+ );
639
+ --color-text-active: light-dark(
640
+ oklch(from var(--color-action) min(l - 0.2, 0.3) c h),
641
+ oklch(from var(--color-action) max(l + 0.1, 0.75) c h)
642
+ );
643
+
644
+ &.accent {
645
+ --color-bg-active: rgb(from var(--color-accent) r g b / 0.08);
646
+ --color-text: light-dark(
647
+ oklch(from var(--color-accent) min(l, 0.5) c h),
648
+ oklch(from var(--color-accent) max(l, 0.65) c h)
649
+ );
650
+ --color-text-disabled: light-dark(
651
+ oklch(from var(--color-accent) min(l + 0.1, 0.6) calc(c - 0.05) h),
652
+ oklch(from var(--color-accent) max(l - 0.1, 0.55) calc(c - 0.05) h)
653
+ );
654
+ --color-text-active: light-dark(
655
+ oklch(from var(--color-accent) min(l - 0.1, 0.4) c h),
656
+ oklch(from var(--color-accent) max(l + 0.1, 0.75) c h)
657
+ );
658
+ }
659
+ &.error {
660
+ --color-bg-active: rgb(from var(--color-error) r g b / 0.08);
661
+ --color-text: light-dark(
662
+ oklch(from var(--color-error) min(l, 0.55) c h),
663
+ oklch(from var(--color-error) max(l, 0.65) c h)
664
+ );
665
+ --color-text-disabled: light-dark(
666
+ oklch(from var(--color-error) min(l + 0.1, 0.65) calc(c - 0.05) h),
667
+ oklch(from var(--color-error) max(l - 0.1, 0.55) calc(c - 0.05) h)
668
+ );
669
+ --color-text-active: light-dark(
670
+ oklch(from var(--color-error) min(l - 0.1, 0.45) c h),
671
+ oklch(from var(--color-error) max(l + 0.1, 0.75) c h)
672
+ );
673
+ }
674
+ &.success {
675
+ --color-bg-active: rgb(from var(--color-success) r g b / 0.08);
676
+ --color-text: light-dark(
677
+ oklch(from var(--color-success) min(l, 0.5) c h),
678
+ oklch(from var(--color-success) max(l, 0.65) c h)
679
+ );
680
+ --color-text-disabled: light-dark(
681
+ oklch(from var(--color-success) min(l + 0.1, 0.6) calc(c - 0.05) h),
682
+ oklch(from var(--color-success) max(l - 0.1, 0.55) calc(c - 0.05) h)
683
+ );
684
+ --color-text-active: light-dark(
685
+ oklch(from var(--color-success) min(l - 0.1, 0.4) c h),
686
+ oklch(from var(--color-success) max(l + 0.1, 0.75) c h)
687
+ );
688
+ }
689
+ }
690
+
691
+ &.transparent {
692
+ --color-bg: transparent;
693
+ --color-bg-disabled: transparent;
694
+ --color-bg-active: rgb(from var(--color-text) r g b / 0.06);
695
+ }
696
+ &.translucent {
697
+ backdrop-filter: blur(10px);
698
+ --color-bg: rgb(from var(--color-text) r g b / 0.09);
699
+ --color-bg-disabled: rgb(from var(--color-text) r g b / 0.04);
700
+ --color-bg-active: rgb(from var(--color-text) r g b / 0.15);
701
+ --button-border: none;
702
+ --button-border-disabled: none;
703
+ --button-border-active: none;
704
+ }
705
+ &.transparent,
706
+ &.translucent {
707
+ /* Plain (non-accent/error/success) transparent + translucent buttons
708
+ don't set their own --color-text, so the global --color-text-disabled
709
+ — a currentColor-relative dim — resolves against the *inherited* color.
710
+ Nested in a muted container (e.g. Breadcrumbs' trail) that compounds and
711
+ washes the disabled label into the background. Derive it from the
712
+ button's own full-contrast --color-text instead; the accent/error/success
713
+ variants below override with their own disabled tokens. */
714
+ --color-text: light-dark(
715
+ oklch(from var(--color-action) min(l, 0.5) c h),
716
+ oklch(from var(--color-action) max(l, 0.65) c h)
717
+ );
718
+ --color-text-disabled: light-dark(
719
+ oklch(from var(--color-action) min(l + 0.1, 0.6) calc(c - 0.05) h),
720
+ oklch(from var(--color-action) max(l - 0.1, 0.55) calc(c - 0.05) h)
721
+ );
722
+ --color-text-active: light-dark(
723
+ oklch(from var(--color-action) min(l - 0.2, 0.3) c h),
724
+ oklch(from var(--color-action) max(l + 0.1, 0.75) c h)
725
+ );
726
+ &.accent {
727
+ --color-text: light-dark(
728
+ oklch(from var(--color-accent) min(l, 0.5) c h),
729
+ oklch(from var(--color-accent) max(l, 0.65) c h)
730
+ );
731
+ --color-text-disabled: light-dark(
732
+ oklch(from var(--color-accent) min(l + 0.1, 0.6) calc(c - 0.05) h),
733
+ oklch(from var(--color-accent) max(l - 0.1, 0.55) calc(c - 0.05) h)
734
+ );
735
+ --color-text-active: light-dark(
736
+ oklch(from var(--color-accent) min(l - 0.1, 0.4) c h),
737
+ oklch(from var(--color-accent) max(l + 0.1, 0.76) c h)
738
+ );
739
+ }
740
+ &.error {
741
+ --color-text: light-dark(
742
+ oklch(from var(--color-error) min(l, 0.55) c h),
743
+ oklch(from var(--color-error) max(l, 0.65) c h)
744
+ );
745
+ --color-text-disabled: light-dark(
746
+ oklch(from var(--color-error) min(l + 0.1, 0.65) calc(c - 0.05) h),
747
+ oklch(from var(--color-error) max(l - 0.1, 0.55) calc(c - 0.05) h)
748
+ );
749
+ --color-text-active: light-dark(
750
+ oklch(from var(--color-error) min(l - 0.1, 0.45) c h),
751
+ oklch(from var(--color-error) max(l + 0.1, 0.75) c h)
752
+ );
753
+ }
754
+ &.success {
755
+ --color-text: light-dark(
756
+ oklch(from var(--color-success) min(l, 0.5) c h),
757
+ oklch(from var(--color-success) max(l, 0.65) c h)
758
+ );
759
+ --color-text-disabled: light-dark(
760
+ oklch(from var(--color-success) min(l + 0.1, 0.6) calc(c - 0.05) h),
761
+ oklch(from var(--color-success) max(l - 0.1, 0.55) calc(c - 0.05) h)
762
+ );
763
+ --color-text-active: light-dark(
764
+ oklch(from var(--color-success) min(l - 0.1, 0.4) c h),
765
+ oklch(from var(--color-success) max(l + 0.1, 0.75) c h)
766
+ );
767
+ }
768
+ }
769
+ &.is-loading {
770
+ cursor: not-allowed;
771
+ a,
772
+ button {
773
+ pointer-events: none;
774
+ /* A loading button is "busy", not "disabled". It still carries the
775
+ disabled attribute (to block clicks/keyboard activation), but it
776
+ must stay fully legible — so restore the resting colors instead
777
+ of the muted disabled treatment. --color-text-disabled is a
778
+ currentColor-relative dim; when a transparent button inherits an
779
+ already-muted color (e.g. Breadcrumbs' muted trail) that dim
780
+ compounds and washes the label almost into the background. */
781
+ &:disabled,
782
+ &[aria-disabled='true'] {
783
+ background-color: var(--color-bg);
784
+ color: var(--color-text);
785
+ border: var(--button-border);
786
+ }
787
+ }
788
+ }
789
+ &.active {
790
+ --color-bg: var(--color-bg-active) !important;
791
+ --color-text: var(--color-text-active) !important;
792
+ }
793
+ &.full-width {
794
+ width: 100%;
795
+ a,
796
+ button:not(.dropdown-trigger) {
797
+ width: 100%;
798
+ }
799
+ }
800
+ &.full-height {
801
+ height: 100%;
802
+ a,
803
+ button {
804
+ height: 100%;
805
+ }
806
+ }
807
+
808
+ .badge {
809
+ position: absolute;
810
+ top: -0.2em;
811
+ right: -0.2em;
812
+ display: flex;
813
+ align-items: center;
814
+ justify-content: center;
815
+ background-color: var(--color-bg, var(--color-action));
816
+ color: var(--color-text, var(--color-action-text));
817
+ border-radius: var(--radius-full);
818
+ font-size: 0.8em;
819
+ line-height: 0.8em;
820
+ padding: 0.1em 0.5em;
821
+ min-width: 1.5em;
822
+ min-height: 1.5em;
823
+ pointer-events: none;
824
+ z-index: 1;
825
+ &.dot {
826
+ width: 0.75rem;
827
+ height: 0.75rem;
828
+ min-width: 0.75rem;
829
+ min-height: 0.75rem;
830
+ top: -0.1em;
831
+ right: -0.1em;
832
+ padding: 0;
833
+ }
834
+ }
835
+ &.transparent,
836
+ &.translucent {
837
+ .badge {
838
+ background-color: var(--color-action);
839
+ color: var(--color-action-text);
840
+ }
841
+ &.accent {
842
+ .badge {
843
+ background-color: var(--color-accent);
844
+ color: var(--color-accent-text);
845
+ }
846
+ }
847
+ &.error {
848
+ .badge {
849
+ background-color: var(--color-error);
850
+ color: var(--color-error-text);
851
+ }
852
+ }
853
+ &.success {
854
+ .badge {
855
+ background-color: var(--color-success);
856
+ color: var(--color-success-text);
857
+ }
858
+ }
859
+ }
860
+ &.icon {
861
+ .badge {
862
+ /* Scale the number badge down relative to the (small) icon
863
+ button and pull it in to hug the circle's top-right edge,
864
+ instead of floating off the bounding-box corner. */
865
+ font-size: 0.8em;
866
+ top: -0.25em;
867
+ right: -0.5em;
868
+ &.dot {
869
+ top: 0;
870
+ right: 0;
871
+ }
872
+ }
873
+ &.transparent,
874
+ &.translucent {
875
+ .badge {
876
+ top: 0.5em;
877
+ right: 0.5em;
878
+
879
+ &.dot {
880
+ top: 0.75em;
881
+ right: 0.75em;
882
+ }
883
+ }
884
+ }
885
+ }
886
+
887
+ /* Badge cutout: mask the inner button so the badge sits in a clean gap */
888
+ &:not(.icon):has(> .badge:not(.dot)) {
889
+ button,
890
+ a {
891
+ mask-image: radial-gradient(
892
+ circle at calc(100% - 0.5em) 0.4em,
893
+ transparent calc(0.65em + 3px),
894
+ black calc(0.65em + 3.5px)
895
+ );
896
+ }
897
+ }
898
+ &:not(.icon):has(> .badge.dot) {
899
+ button,
900
+ a {
901
+ mask-image: radial-gradient(
902
+ circle at calc(100% - 0.3rem) 0.3rem,
903
+ transparent calc(0.375rem + 3px),
904
+ black calc(0.375rem + 3.55px)
905
+ );
906
+ }
907
+ }
908
+ /* Icon button badge cutouts */
909
+ &.icon:has(> .badge:not(.dot)) {
910
+ button,
911
+ a {
912
+ mask-image: radial-gradient(
913
+ circle at calc(100% - 0.3em) 0.4em,
914
+ transparent 0.75em,
915
+ black calc(0.75em + 0.5px)
916
+ );
917
+ }
918
+ }
919
+ &.icon:has(> .badge.dot) {
920
+ button,
921
+ a {
922
+ mask-image: radial-gradient(
923
+ circle at calc(100% - 0.375rem) 0.375rem,
924
+ transparent calc(0.375rem + 3px),
925
+ black calc(0.375rem + 3.5px)
926
+ );
927
+ }
928
+ }
929
+ &.icon.transparent:has(> .badge:not(.dot)),
930
+ &.icon.translucent:has(> .badge:not(.dot)) {
931
+ button,
932
+ a {
933
+ mask-image: radial-gradient(
934
+ circle at calc(100% - 0.75em) 0.75em,
935
+ transparent 0.5em,
936
+ black calc(0.5em + 0.5px)
937
+ );
938
+ }
939
+ }
940
+ &.icon.transparent:has(> .badge.dot),
941
+ &.icon.translucent:has(> .badge.dot) {
942
+ button,
943
+ a {
944
+ mask-image: radial-gradient(
945
+ circle at calc(100% - 0.75em - 0.375rem) calc(0.75em + 0.375rem),
946
+ transparent calc(0.375rem + 3px),
947
+ black calc(0.375rem + 3.5px)
948
+ );
949
+ }
950
+ }
951
+
952
+ button,
953
+ a {
954
+ display: flex;
955
+ align-items: center;
956
+ justify-content: center;
957
+ cursor: pointer;
958
+ position: relative;
959
+ overflow: hidden;
960
+ outline: none;
961
+ border: var(--button-border);
962
+ text-align: center;
963
+ text-decoration: none;
964
+ width: fit-content;
965
+ border-radius: var(--_radius);
966
+ @supports (corner-shape: squircle) {
967
+ corner-shape: var(--_corner-shape);
968
+ border-radius: calc(var(--_radius) * var(--_corner-scale));
969
+ }
970
+ background-color: var(--color-bg);
971
+ color: var(--color-text);
972
+ cursor: pointer;
973
+ padding: 0.75em 1.5em;
974
+ /* Set an explicit line-height so the label centers symmetrically
975
+ * regardless of the host page's inherited line-height. Inheriting a
976
+ * loose prose line-height (e.g. 1.75) leaves fractional half-leading
977
+ * that rounds unevenly at small font sizes, pushing the text upward. */
978
+ line-height: normal;
979
+ transition:
980
+ background-color 300ms,
981
+ color 300ms,
982
+ box-shadow 300ms ease,
983
+ translate 200ms ease;
984
+ box-shadow: inset 0px 0px 0px 0px var(--color-text);
985
+ gap: 0.5em;
986
+
987
+ &:focus-visible:not(:disabled):not([aria-disabled='true']) {
988
+ box-shadow: inset 0px 0px 0px 2px var(--color-text);
989
+ outline: solid 2px var(--color-bg);
990
+ }
991
+ &:disabled,
992
+ &[aria-disabled='true'] {
993
+ background-color: var(--color-bg-disabled);
994
+ color: var(--color-text-disabled);
995
+ cursor: not-allowed;
996
+ border: var(--button-border-disabled);
997
+ }
998
+ /* While loading the button is "busy" and shouldn't react to pointer
999
+ interaction — gate :hover/:active on :not([aria-busy='true']) so it
1000
+ behaves like a disabled control (aria-busy is set whenever isLoading,
1001
+ covering anchors and prop/form-driven loading that aren't :disabled). */
1002
+ &:hover:not(:disabled):not([aria-disabled='true']):not([aria-busy='true']) {
1003
+ background-color: var(--color-bg-active);
1004
+ color: var(--color-text-active);
1005
+ border: var(--button-border-active);
1006
+ text-decoration: none;
1007
+ transition: translate 200ms ease;
1008
+ }
1009
+ &:active:not(:disabled):not([aria-disabled='true']):not([aria-busy='true']) {
1010
+ translate: 0px 1px clamp(-10px, calc(0.2em - 12px), -2px);
1011
+ }
1012
+ }
1013
+
1014
+ /* Shared control height: a standalone (non-icon) button snaps to the
1015
+ same height as Input/Select for a given size (see --control-height-*
1016
+ in tokens.css), so a Button lines up in a form row. The floor is
1017
+ em-based, so an explicitly sized button scales up too. The
1018
+ dropdown-trigger is excluded — it stretches to match its sibling.
1019
+ Icon buttons keep their 4em square (and so do the font-scaled
1020
+ buttons embedded inside Input/Select). */
1021
+ &:not(.icon) {
1022
+ button:not(.dropdown-trigger),
1023
+ a {
1024
+ box-sizing: border-box;
1025
+ min-height: calc(1em * var(--control-height-ratio, 3));
1026
+ }
1027
+ &.dense {
1028
+ button:not(.dropdown-trigger),
1029
+ a {
1030
+ min-height: calc(1em * var(--control-height-ratio-dense, 2.5));
1031
+ }
1032
+ }
1033
+ &.comfortable {
1034
+ button:not(.dropdown-trigger),
1035
+ a {
1036
+ min-height: calc(1em * var(--control-height-ratio-comfortable, 3.5));
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ .loading-icon {
1042
+ position: relative;
1043
+ display: flex;
1044
+ justify-content: center;
1045
+ align-items: center;
1046
+ width: 1.5em;
1047
+ margin-left: -0.75em;
1048
+ margin-right: -0.25em;
1049
+ height: 100%;
1050
+ flex-shrink: 0;
1051
+ flex-grow: 0;
1052
+ :global(.logo) {
1053
+ display: block;
1054
+ width: 100%;
1055
+ height: auto;
1056
+ aspect-ratio: 1;
1057
+ flex-shrink: 0;
1058
+ flex-grow: 0;
1059
+ }
1060
+ :global(circle.track) {
1061
+ stroke: rgb(from currentColor r g b / 0.2);
1062
+ }
1063
+ /* Spinner and success check occupy the same spot so they can
1064
+ crossfade during the handoff without nudging the label. */
1065
+ .icon-layer {
1066
+ position: absolute;
1067
+ inset: 0;
1068
+ display: flex;
1069
+ align-items: center;
1070
+ justify-content: center;
1071
+ }
1072
+ .check {
1073
+ display: block;
1074
+ /* The tick only spans the middle of its viewBox, so at 1rem it read
1075
+ much smaller than the spinner ring. Fill the slot (paired with the
1076
+ tightened viewBox above) so it's sized like the spinner. The slot
1077
+ width is fixed and the layer is absolutely positioned, so this
1078
+ never shifts layout. */
1079
+ width: 1.25em;
1080
+ height: 1.25em;
1081
+ }
1082
+ .check path {
1083
+ /* Dash length >= the tick's path length; checkIn() rides
1084
+ --check-draw from 24 (hidden) down to 0 (fully drawn). The 0
1085
+ fallback keeps it drawn once the transition's inline style is
1086
+ gone. */
1087
+ stroke-dasharray: 24;
1088
+ stroke-dashoffset: var(--check-draw, 0);
1089
+ }
1090
+ }
1091
+
1092
+ :global(.chevron) {
1093
+ display: flex;
1094
+ align-items: center;
1095
+ justify-content: center;
1096
+ pointer-events: none;
1097
+ transform: rotate(0);
1098
+ transition: transform 300ms var(--easing);
1099
+ }
1100
+ :global(.chevron.active) {
1101
+ transform: rotate(180deg);
1102
+ }
1103
+
1104
+ &.has-dropdown-trigger {
1105
+ > button:not(.dropdown-trigger),
1106
+ > a {
1107
+ border-top-right-radius: 0;
1108
+ border-bottom-right-radius: 0;
1109
+ padding-right: 0.75em;
1110
+ }
1111
+ > button,
1112
+ > a {
1113
+ &:first-child {
1114
+ border-right: none !important;
1115
+ }
1116
+ &:last-child {
1117
+ border-left: none !important;
1118
+ }
1119
+ }
1120
+ }
1121
+ .dropdown-trigger {
1122
+ border-top-left-radius: 0;
1123
+ border-bottom-left-radius: 0;
1124
+ border-top-right-radius: var(--_radius);
1125
+ border-bottom-right-radius: var(--_radius);
1126
+ @supports (corner-shape: squircle) {
1127
+ corner-shape: var(--_corner-shape);
1128
+ border-top-right-radius: calc(var(--_radius) * var(--_corner-scale));
1129
+ border-bottom-right-radius: calc(var(--_radius) * var(--_corner-scale));
1130
+ }
1131
+ display: flex;
1132
+ align-items: center;
1133
+ padding: 0 0.5em 0 0.5em;
1134
+ &::before {
1135
+ content: '';
1136
+ height: 1em;
1137
+ margin: 0 -0.25em 0 -0.5em;
1138
+ padding: 0;
1139
+ background-color: var(--color-text);
1140
+ width: 1px;
1141
+ opacity: 0.2;
1142
+ }
1143
+ :global(svg) {
1144
+ width: 1.5em;
1145
+ height: 1.5em;
1146
+ }
1147
+ }
1148
+
1149
+ &.dense {
1150
+ button:not(.dropdown-trigger),
1151
+ a {
1152
+ line-height: 1em;
1153
+ padding: 0.5em 1em;
1154
+ gap: 0.3em;
1155
+ }
1156
+ &.has-dropdown-trigger {
1157
+ > button:not(.dropdown-trigger),
1158
+ > a {
1159
+ padding: 0.5em 1em 0.5em 1.1em;
1160
+ }
1161
+ }
1162
+ :global(.chevron) {
1163
+ margin: 0 -0.2em;
1164
+ }
1165
+ .loading-icon {
1166
+ margin-left: -0.5em;
1167
+ margin-right: -0.15em;
1168
+ }
1169
+ }
1170
+
1171
+ &.comfortable {
1172
+ button:not(.dropdown-trigger),
1173
+ a {
1174
+ padding: 1em 2em;
1175
+ gap: 0.65em;
1176
+ }
1177
+ &.has-dropdown-trigger {
1178
+ > button:not(.dropdown-trigger),
1179
+ > a {
1180
+ padding: 1em 2em 1em 1.5em;
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ &.icon {
1186
+ /* A standalone icon button is a control-height square so it lines up
1187
+ in a row with text controls (Input/Select/Button). It scales with
1188
+ the button's font, so a sized icon button grows too. Icon buttons
1189
+ embedded in a field (.input-icon-btn / .input-pill-btn) pin their
1190
+ own size and are unaffected. */
1191
+ --_icon-size: calc(1em * var(--control-height-ratio, 3));
1192
+ height: var(--_icon-size);
1193
+ width: var(--_icon-size);
1194
+ aspect-ratio: 1 / 1;
1195
+ /* Make the icon button a real circle by setting --_radius on the
1196
+ inner button/a (which owns the background, border, ripple AND the
1197
+ :active translate). Don't clip the circle from this wrapper with
1198
+ overflow:hidden — that fakes the shape (so the square inner radius
1199
+ shows through on :active and the outline border gets clipped) and
1200
+ crops the badge that hangs off the corner. The inner element's own
1201
+ overflow:hidden still clips the ripple to the circle. */
1202
+ --_radius: var(--radius-full);
1203
+ --_corner-shape: round;
1204
+ --_corner-scale: 1;
1205
+ &.dense {
1206
+ --_icon-size: calc(1em * var(--control-height-ratio-dense, 2.5));
1207
+ --_feedback-size: 60%;
1208
+ button,
1209
+ a {
1210
+ padding: 0;
1211
+ :global(> svg),
1212
+ :global(> img) {
1213
+ width: 60%;
1214
+ height: 60%;
1215
+ }
1216
+ }
1217
+ }
1218
+ &.comfortable {
1219
+ --_icon-size: calc(1em * var(--control-height-ratio-comfortable, 3.5));
1220
+ }
1221
+ button,
1222
+ a {
1223
+ align-items: center;
1224
+ justify-content: center;
1225
+ aspect-ratio: 1 / 1;
1226
+ padding: 0;
1227
+ width: 100%;
1228
+ height: 100%;
1229
+ :global(svg) {
1230
+ width: 50%;
1231
+ height: 50%;
1232
+ }
1233
+ /* The icon eases away/back as the loading/success feedback overlay
1234
+ crossfades over it. */
1235
+ :global(> svg),
1236
+ :global(> img) {
1237
+ transition:
1238
+ opacity 150ms ease,
1239
+ scale 200ms var(--ease-spring, ease);
1240
+ }
1241
+ .loading-icon ~ :global(svg),
1242
+ .loading-icon ~ :global(img) {
1243
+ opacity: 0;
1244
+ scale: 0.5;
1245
+ }
1246
+ }
1247
+ /* Loading/success feedback covers the whole square button instead of
1248
+ the width-growing slot used beside a label. Sized to match the icon
1249
+ it replaces (50%, 60% when dense). */
1250
+ --_feedback-size: 50%;
1251
+ .loading-icon {
1252
+ position: absolute;
1253
+ inset: 0;
1254
+ width: auto;
1255
+ margin: 0;
1256
+ :global(.progress) {
1257
+ width: var(--_feedback-size);
1258
+ height: var(--_feedback-size);
1259
+ }
1260
+ :global(.progress svg) {
1261
+ width: 100%;
1262
+ height: 100%;
1263
+ }
1264
+ .check {
1265
+ width: var(--_feedback-size);
1266
+ height: var(--_feedback-size);
1267
+ }
1268
+ }
1269
+ }
1270
+ /* Grouped attached: border-radius adjustments and border merging */
1271
+ &.group-h,
1272
+ &.group-v {
1273
+ &:hover,
1274
+ &:focus-within {
1275
+ z-index: 1;
1276
+ }
1277
+ }
1278
+ &.group-h {
1279
+ &:not(:first-child):not(:last-child) {
1280
+ border-radius: 0;
1281
+ button,
1282
+ a {
1283
+ border-radius: 0;
1284
+ }
1285
+ }
1286
+ &:first-child:not(:last-child) {
1287
+ border-top-right-radius: 0;
1288
+ border-bottom-right-radius: 0;
1289
+ button,
1290
+ a {
1291
+ border-top-right-radius: 0;
1292
+ border-bottom-right-radius: 0;
1293
+ }
1294
+ }
1295
+ &:last-child:not(:first-child) {
1296
+ border-top-left-radius: 0;
1297
+ border-bottom-left-radius: 0;
1298
+ button,
1299
+ a {
1300
+ border-top-left-radius: 0;
1301
+ border-bottom-left-radius: 0;
1302
+ }
1303
+ }
1304
+ /* Remove right border on non-last so outline buttons share a single border */
1305
+ &:not(:last-child) {
1306
+ button,
1307
+ a {
1308
+ border-right: none;
1309
+ }
1310
+ }
1311
+ /* Icon-only ends: the pill curve crowds a centered icon at the
1312
+ rounded outer end. Widen the end button and pad its rounded side
1313
+ so the icon keeps its size and its spacing from the flat (inner)
1314
+ edge while gaining a little breathing room from the curve. */
1315
+ &.icon {
1316
+ --_group-end-pad: 0.5em;
1317
+ &:first-child:not(:last-child) {
1318
+ width: calc(var(--_icon-size) + var(--_group-end-pad));
1319
+ button,
1320
+ a {
1321
+ box-sizing: border-box;
1322
+ padding-left: var(--_group-end-pad);
1323
+ }
1324
+ }
1325
+ &:last-child:not(:first-child) {
1326
+ width: calc(var(--_icon-size) + var(--_group-end-pad));
1327
+ button,
1328
+ a {
1329
+ box-sizing: border-box;
1330
+ padding-right: var(--_group-end-pad);
1331
+ }
1332
+ }
1333
+ }
1334
+ & + :global(.button) {
1335
+ margin-left: -1px;
1336
+ }
1337
+ }
1338
+ &.group-v {
1339
+ width: auto;
1340
+ button,
1341
+ a {
1342
+ width: 100%;
1343
+ }
1344
+ &:not(:first-child):not(:last-child) {
1345
+ border-radius: 0;
1346
+ button,
1347
+ a {
1348
+ border-radius: 0;
1349
+ }
1350
+ }
1351
+ &:first-child:not(:last-child) {
1352
+ border-bottom-left-radius: 0;
1353
+ border-bottom-right-radius: 0;
1354
+ button,
1355
+ a {
1356
+ border-bottom-left-radius: 0;
1357
+ border-bottom-right-radius: 0;
1358
+ }
1359
+ }
1360
+ &:last-child:not(:first-child) {
1361
+ border-top-left-radius: 0;
1362
+ border-top-right-radius: 0;
1363
+ button,
1364
+ a {
1365
+ border-top-left-radius: 0;
1366
+ border-top-right-radius: 0;
1367
+ }
1368
+ }
1369
+ /* Remove bottom border on non-last so outline buttons share a single border */
1370
+ &:not(:last-child) {
1371
+ button,
1372
+ a {
1373
+ border-bottom: none;
1374
+ }
1375
+ }
1376
+ /* Icon-only ends: same breathing room as horizontal, but the rounded
1377
+ ends are top/bottom here, so pad the block axis. */
1378
+ &.icon {
1379
+ --_group-end-pad: 0.5em;
1380
+ &:first-child:not(:last-child) {
1381
+ height: calc(var(--_icon-size) + var(--_group-end-pad));
1382
+ button,
1383
+ a {
1384
+ box-sizing: border-box;
1385
+ padding-top: var(--_group-end-pad);
1386
+ }
1387
+ }
1388
+ &:last-child:not(:first-child) {
1389
+ height: calc(var(--_icon-size) + var(--_group-end-pad));
1390
+ button,
1391
+ a {
1392
+ box-sizing: border-box;
1393
+ padding-bottom: var(--_group-end-pad);
1394
+ }
1395
+ }
1396
+ }
1397
+ & + :global(.button) {
1398
+ margin-top: -1px;
1399
+ }
1400
+ }
1401
+
1402
+ &.overlay {
1403
+ --button-border: none;
1404
+ --button-border-disabled: none;
1405
+ --button-border-active: none;
1406
+ &.active {
1407
+ button,
1408
+ a {
1409
+ color: rgba(255, 255, 255, 1);
1410
+ background-color: rgba(0, 0, 0, 0.9);
1411
+ @supports (backdrop-filter: blur(10px)) {
1412
+ background-color: rgba(0, 0, 0, 0.8);
1413
+ }
1414
+ }
1415
+ }
1416
+ button,
1417
+ a {
1418
+ color: rgba(255, 255, 255, 0.85);
1419
+ backdrop-filter: blur(10px);
1420
+ background-color: rgba(0, 0, 0, 0.85);
1421
+ @supports (backdrop-filter: blur(10px)) {
1422
+ background-color: rgba(0, 0, 0, 0.65);
1423
+ }
1424
+ &:disabled,
1425
+ &[aria-disabled='true'] {
1426
+ color: rgba(255, 255, 255, 0.65);
1427
+ }
1428
+ &:focus-visible:not(:disabled):not([aria-disabled='true']) {
1429
+ box-shadow: none;
1430
+ outline: solid 2px white;
1431
+ outline-offset: 1px;
1432
+ }
1433
+ &:hover:not(:disabled):not([aria-disabled='true']):not([aria-busy='true']) {
1434
+ transition: none;
1435
+ }
1436
+ &:hover:not(:disabled):not([aria-disabled='true']):not([aria-busy='true']),
1437
+ &:focus-visible:not(:disabled):not([aria-disabled='true']) {
1438
+ color: rgba(255, 255, 255, 1);
1439
+ background-color: rgba(0, 0, 0, 0.9);
1440
+ @supports (backdrop-filter: blur(10px)) {
1441
+ background-color: rgba(0, 0, 0, 0.75);
1442
+ }
1443
+ }
1444
+ &::before {
1445
+ background-color: rgba(0, 0, 0, 1);
1446
+ }
1447
+ }
1448
+ }
1449
+ }
1450
+ </style>