@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,834 @@
1
+ <script lang="ts">
2
+ import { ripple } from '@delightstack/utilities';
3
+ import { getContext, type Snippet } from 'svelte';
4
+ import { type ListContext } from './List.svelte';
5
+ import { fade, type TransitionConfig } from 'svelte/transition';
6
+ import { backOut, quartOut } from 'svelte/easing';
7
+ import type { PopoverPlacement } from './../actions/Popover.svelte';
8
+ import Button from './../actions/Button.svelte';
9
+ import Progress from '../feedback/Progress.svelte';
10
+ import Checkbox from '../form/Checkbox.svelte';
11
+ import Radio from '../form/Radio.svelte';
12
+ import Toggle from '../form/Toggle.svelte';
13
+ import ListContextReset from './ListContextReset.svelte';
14
+
15
+ const propId = $props.id();
16
+ let {
17
+ /** Whether this button/checkbox/radio should be disabled */
18
+ disabled = false,
19
+
20
+ /** The target of the link (only used if href is provided) */
21
+ target = undefined as '_self' | '_blank' | '_parent' | '_top' | undefined,
22
+
23
+ /** The link the user should be navigated to (uses an 'a' tag instead of the button) */
24
+ href = undefined as string | undefined,
25
+
26
+ /** Whether the list item is active (like when used as a button selection list) */
27
+ active = false,
28
+
29
+ /**
30
+ * Whether a loading spinner should appear on the action row (`button`
31
+ * type). Leave undefined to let a promise-returning `onclick` drive it
32
+ * automatically (see `onclick`).
33
+ */
34
+ loading = undefined as boolean | undefined,
35
+
36
+ /**
37
+ * For the manual `loading` path only: when `loading` goes true -> false and
38
+ * this is true, a success checkmark briefly animates in to confirm the
39
+ * action, then animates away. (The promise-aware `onclick` path shows this
40
+ * checkmark automatically on resolve, so this prop isn't needed there.)
41
+ */
42
+ loading_success = false,
43
+
44
+ /** The content to show in a dropdown menu when the button is clicked */
45
+ menu = undefined as undefined | Snippet,
46
+
47
+ /** Whether the dropdown menu should close when the user clicks a button like element inside of it */
48
+ popover_close_on_inside_click = false,
49
+
50
+ /** The placement of the popover (used when either "menu" or "dropdown" is provided) */
51
+ popover_placement = 'bottom-end' as PopoverPlacement,
52
+
53
+ /** The css style string added to the component from the parent */
54
+ style = '',
55
+
56
+ /** The ID of the select element. @defaults to a random ID */
57
+ id = propId,
58
+
59
+ /** Specifies a custom class name for the container element */
60
+ class: class_name = '',
61
+
62
+ /** The child elements to display inside the component */
63
+ children = undefined as undefined | Snippet,
64
+
65
+ /** Emits when the list item is selected/deselected */
66
+ onchange = undefined as ((value: boolean) => void) | undefined,
67
+
68
+ /**
69
+ * The function to call when the list item is clicked.
70
+ * If it returns a promise, the row manages its own loading feedback:
71
+ * - A spinner appears only if the promise is still pending after ~100ms
72
+ * (faster resolves are treated as instant — no spinner flash).
73
+ * - Once shown, the spinner stays for at least ~1s so it can't blink away.
74
+ * - On resolve, a brief success checkmark confirms the action; on reject,
75
+ * no checkmark is shown.
76
+ */
77
+ onclick = undefined as
78
+ | undefined
79
+ | ((e: MouseEvent) => void)
80
+ | ((e: MouseEvent) => Promise<void>),
81
+ } = $props();
82
+
83
+ let element = $state<HTMLElement | undefined>(undefined);
84
+ let checked = $state(false);
85
+ const context = getContext<ListContext | undefined>('list');
86
+
87
+ $effect(() => {
88
+ if (!context?.value) return;
89
+ if (!element?.parentElement?.children) return;
90
+ const index = Array.from(element.parentElement.children).indexOf(element);
91
+ checked = context.value.includes(index);
92
+ });
93
+
94
+ /* Promise-aware loading timing (mirrors Button).
95
+ - SHOW_DELAY: a promise that settles faster than this never gets a spinner;
96
+ the action reads as "instant".
97
+ - MIN_VISIBLE: once shown, the spinner stays at least this long so it can't
98
+ flash on then immediately off.
99
+ - SPINNER_OUT: spinner collapse duration (kept in sync with the "out"
100
+ transition) so the success check can slot in right after it clears.
101
+ - CHECK_HOLD: how long the success checkmark lingers before easing out. */
102
+ const SHOW_DELAY = 100;
103
+ const MIN_VISIBLE = 1000;
104
+ const SPINNER_OUT = 150;
105
+ const CHECK_HOLD = 1000;
106
+
107
+ let inFlight = $state(false); // a returned promise is running (covers the pre-spinner window)
108
+ let spinnerVisible = $state(false); // the spinner is actually rendered
109
+ let checkVisible = $state(false); // the success checkmark is rendered
110
+
111
+ let showTimer: ReturnType<typeof setTimeout> | undefined;
112
+ let hideTimer: ReturnType<typeof setTimeout> | undefined;
113
+ let checkTimer: ReturnType<typeof setTimeout> | undefined;
114
+ let spinnerShownAt = 0;
115
+
116
+ function clearTimers() {
117
+ clearTimeout(showTimer);
118
+ clearTimeout(hideTimer);
119
+ clearTimeout(checkTimer);
120
+ showTimer = hideTimer = checkTimer = undefined;
121
+ }
122
+ $effect(() => clearTimers); // tear down pending timers on destroy
123
+
124
+ // The external `loading` prop drives the spinner directly; a returned promise
125
+ // drives `inFlight`/`spinnerVisible`. "Busy" (a11y/pointer-gating) is either of
126
+ // those; `showSpinner` excludes the brief pre-spinner window so a sub-SHOW_DELAY
127
+ // promise never flashes one. The checkmark renders straight off `checkVisible`.
128
+ const externalLoading = $derived(loading);
129
+ const isLoading = $derived(!!externalLoading || inFlight);
130
+ const showSpinner = $derived(!!externalLoading || spinnerVisible);
131
+
132
+ // Manual loading path: when the caller drives `loading` true -> false and has
133
+ // opted in with `loading_success`, play the same confirming checkmark. Runs in
134
+ // `$effect.pre` so `checkVisible` is set in the same flush `loading` clears in,
135
+ // otherwise the icon slot would render one empty frame and flash closed/open.
136
+ let wasExternalLoading = false;
137
+ $effect.pre(() => {
138
+ const now = !!externalLoading;
139
+ if (wasExternalLoading && !now && loading_success && !inFlight) flashCheck();
140
+ wasExternalLoading = now;
141
+ });
142
+
143
+ // The icon slot (the common parent of the spinner and the success check)
144
+ // grows/collapses with this width+opacity transition. Wrapping both means the
145
+ // slot only opens when one first appears and only collapses once both are gone
146
+ // — the spinner -> check handoff happens inside a stable, already-open slot.
147
+ //
148
+ // The margins (and any parent flex gap) ride `t` along with the width so the
149
+ // slot's total layout contribution hits exactly 0 at t=0 — animating width
150
+ // alone leaves them at full strength, which over/under-shoots the label's
151
+ // resting position and snaps it when the node is finally removed (see the
152
+ // matching note in Button.svelte).
153
+ function loadingTransition(
154
+ node: HTMLElement,
155
+ params?: { direction?: 'in' | 'out' },
156
+ ): () => TransitionConfig {
157
+ return () => {
158
+ const style = getComputedStyle(node);
159
+ const width = parseFloat(style.width);
160
+ const marginLeft = parseFloat(style.marginLeft) || 0;
161
+ const marginRight = parseFloat(style.marginRight) || 0;
162
+ const gap = node.parentElement
163
+ ? parseFloat(getComputedStyle(node.parentElement).columnGap) || 0
164
+ : 0;
165
+ const out = params?.direction === 'out';
166
+ return {
167
+ duration: out ? SPINNER_OUT : 320,
168
+ easing: out ? quartOut : backOut,
169
+ css: (t: number) =>
170
+ `width: ${t * width}px; ` +
171
+ `margin-left: ${t * marginLeft}px; ` +
172
+ `margin-right: ${t * marginRight - (1 - t) * gap}px; ` +
173
+ `opacity: ${t};`,
174
+ };
175
+ };
176
+ }
177
+
178
+ // The checkmark's own entrance: a spring-scaled pop that simultaneously draws
179
+ // its stroke on (the dash offset rides `t`). Driving the draw from the
180
+ // transition — rather than a CSS @keyframes — keeps it reliable regardless of
181
+ // scoping. prefers-reduced-motion collapses it to a plain appear.
182
+ function checkIn(_node: Element): TransitionConfig {
183
+ const reduce =
184
+ typeof matchMedia !== 'undefined' &&
185
+ matchMedia('(prefers-reduced-motion: reduce)').matches;
186
+ return {
187
+ duration: reduce ? 0 : 440,
188
+ easing: backOut,
189
+ css: (t: number) =>
190
+ `transform: scale(${0.3 + 0.7 * t}); opacity: ${Math.min(1, t * 2)}; --check-draw: ${24 * (1 - t)};`,
191
+ };
192
+ }
193
+
194
+ // Pop the confirming checkmark, then retire it after CHECK_HOLD. spinnerVisible
195
+ // is cleared in the same tick by the caller, so the slot stays open and the
196
+ // spinner crossfades into the check rather than the slot reopening.
197
+ function flashCheck() {
198
+ clearTimeout(checkTimer);
199
+ checkVisible = true;
200
+ checkTimer = setTimeout(() => (checkVisible = false), CHECK_HOLD);
201
+ }
202
+
203
+ function handleClick(e: MouseEvent) {
204
+ if (inFlight || externalLoading) return;
205
+ if (!onclick) return;
206
+ const maybePromise = onclick(e);
207
+ if (!(maybePromise instanceof Promise)) return;
208
+
209
+ // A fresh action supersedes any checkmark still lingering from the last one.
210
+ clearTimers();
211
+ checkVisible = false;
212
+
213
+ inFlight = true;
214
+ // Hold off on the spinner — if the promise settles within SHOW_DELAY the
215
+ // action was effectively instant and never needs one.
216
+ showTimer = setTimeout(() => {
217
+ showTimer = undefined;
218
+ spinnerVisible = true;
219
+ spinnerShownAt = performance.now();
220
+ }, SHOW_DELAY);
221
+
222
+ maybePromise.then(
223
+ () => settle(true),
224
+ () => settle(false),
225
+ );
226
+ }
227
+
228
+ function settle(success: boolean) {
229
+ // Settled before the spinner ever appeared -> treat as instant: no spinner,
230
+ // no checkmark, just release.
231
+ if (showTimer) {
232
+ clearTimeout(showTimer);
233
+ showTimer = undefined;
234
+ inFlight = false;
235
+ return;
236
+ }
237
+ // The spinner is up; keep it for the rest of its minimum-visible window so
238
+ // it doesn't blink away the instant the promise resolves.
239
+ const remaining = Math.max(0, MIN_VISIBLE - (performance.now() - spinnerShownAt));
240
+ clearTimeout(hideTimer);
241
+ hideTimer = setTimeout(() => {
242
+ spinnerVisible = false;
243
+ inFlight = false;
244
+ // On success, let the spinner collapse, then pop a brief checkmark.
245
+ if (success) flashCheck();
246
+ }, remaining);
247
+ }
248
+ </script>
249
+
250
+ {#if context}
251
+ <li
252
+ class={['list-item', context.type, class_name].filter(Boolean).join(' ')}
253
+ class:disabled={context.disabled || disabled}
254
+ class:is-loading={isLoading}
255
+ class:dense={context.dense}
256
+ class:comfortable={context.comfortable}
257
+ {style}
258
+ {id}
259
+ bind:this={element}
260
+ class:active={checked || active}
261
+ style:--level={context.level}
262
+ {@attach ripple({
263
+ zIndex: 1,
264
+ enabled: !context.disabled && !disabled && context.type !== 'text' && !isLoading,
265
+ })}>
266
+ {#if context.type === 'checkbox'}
267
+ <label for="checkbox-{id}">
268
+ {#if children}{@render children()}{/if}
269
+ <div class="spacer"></div>
270
+ <input
271
+ type="checkbox"
272
+ id="checkbox-{id}"
273
+ name={id}
274
+ disabled={context.disabled || disabled}
275
+ {checked}
276
+ onchange={() => onchange?.(checked)} />
277
+ <!-- Presentational only: the hidden native input above owns
278
+ interaction, focus and a11y. `inert` keeps the Checkbox from
279
+ becoming a second focusable/clickable control. -->
280
+ <span class="control" inert>
281
+ <Checkbox
282
+ {checked}
283
+ disabled={context.disabled || disabled}
284
+ size={context.dense ? '0' : '1'} />
285
+ </span>
286
+ </label>
287
+ {:else if context.type === 'toggle'}
288
+ <label for="toggle-{id}">
289
+ {#if children}{@render children()}{/if}
290
+ <div class="spacer"></div>
291
+ <input
292
+ type="checkbox"
293
+ id="toggle-{id}"
294
+ name={id}
295
+ disabled={context.disabled || disabled}
296
+ {checked}
297
+ onchange={() => onchange?.(checked)} />
298
+ <!-- Presentational only: the hidden native input above owns
299
+ interaction, focus and a11y (List's change delegation treats it
300
+ like a checkbox). `inert` keeps the Toggle from becoming a
301
+ second focusable/clickable control. -->
302
+ <span class="control" inert>
303
+ <Toggle
304
+ {checked}
305
+ disabled={context.disabled || disabled}
306
+ size={context.dense ? '0' : '1'} />
307
+ </span>
308
+ </label>
309
+ {:else if context.type === 'radio'}
310
+ <label for="radio-{id}">
311
+ {#if children}{@render children()}{/if}
312
+ <div class="spacer"></div>
313
+ <input
314
+ type="radio"
315
+ disabled={context.disabled || disabled}
316
+ id="radio-{id}"
317
+ name={context.id}
318
+ onchange={() => onchange?.(checked)}
319
+ {checked} />
320
+ <span class="control" inert>
321
+ <Radio
322
+ {checked}
323
+ disabled={context.disabled || disabled}
324
+ size={context.dense ? '0' : '1'} />
325
+ </span>
326
+ </label>
327
+ {:else if context.type === 'button'}
328
+ {#if href}
329
+ <a aria-disabled={context.disabled || disabled} {href} {target}>
330
+ {#if children}{@render children()}{/if}
331
+ </a>
332
+ {:else}
333
+ <button
334
+ type="button"
335
+ disabled={context.disabled || disabled}
336
+ aria-busy={isLoading ? 'true' : null}
337
+ onclick={handleClick}>
338
+ {#if showSpinner || checkVisible}
339
+ <div
340
+ class="loading-icon"
341
+ in:loadingTransition={{ direction: 'in' }}
342
+ out:loadingTransition={{ direction: 'out' }}>
343
+ {#if showSpinner}
344
+ <div class="icon-layer" out:fade={{ duration: 120 }}>
345
+ <Progress size="00" color="currentColor" />
346
+ </div>
347
+ {:else}
348
+ <div class="icon-layer check-layer" in:checkIn>
349
+ <svg
350
+ class="check"
351
+ viewBox="2 2 20 20"
352
+ fill="none"
353
+ stroke="currentColor"
354
+ stroke-width="3"
355
+ stroke-linecap="round"
356
+ stroke-linejoin="round"
357
+ aria-hidden="true">
358
+ <path d="M5 12.5l4.5 4.5L19 7" />
359
+ </svg>
360
+ </div>
361
+ {/if}
362
+ </div>
363
+ {/if}
364
+ {#if children}{@render children()}{/if}
365
+ </button>
366
+ {/if}
367
+ {:else if context.type === 'text'}
368
+ <span class="text-content">
369
+ {#if children}{@render children()}{/if}
370
+ </span>
371
+ {/if}
372
+ {#if menu}
373
+ <Button
374
+ icon
375
+ transparent
376
+ size="0"
377
+ class="action"
378
+ {popover_close_on_inside_click}
379
+ {popover_placement}
380
+ menu={resetMenu}>
381
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
382
+ <path
383
+ d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
384
+ </svg>
385
+ </Button>
386
+ {/if}
387
+ </li>
388
+ {/if}
389
+
390
+ {#snippet resetMenu()}
391
+ <ListContextReset>
392
+ {#if menu}{@render menu()}{/if}
393
+ </ListContextReset>
394
+ {/snippet}
395
+
396
+ <style>
397
+ li {
398
+ min-height: 3rem;
399
+ padding: 0;
400
+ margin: 0;
401
+ position: relative;
402
+ overflow: hidden;
403
+ list-style: none;
404
+ display: flex;
405
+ align-items: center;
406
+ perspective: 100px;
407
+ --_radius: calc(var(--radius-lg) * 1.5);
408
+ :global(> .ripple) {
409
+ inset: 1px var(--border-inset) 1px
410
+ calc(var(--border-inset) + ((var(--level) - 1) * 1rem)) !important;
411
+ border-radius: calc(var(--_radius) - var(--border-inset)) !important;
412
+ @supports (corner-shape: squircle) {
413
+ corner-shape: squircle;
414
+ border-radius: calc(
415
+ (var(--_radius) - var(--border-inset)) * var(--squircle-ratio, 2)
416
+ ) !important;
417
+ }
418
+ }
419
+ &.active {
420
+ a,
421
+ button,
422
+ label {
423
+ &::before {
424
+ opacity: 0.06;
425
+ /* Snap the active highlight in instantly (so keyboard navigation —
426
+ e.g. arrowing an autocomplete list — feels immediate). The base
427
+ ::before rule still eases it out when the item is deselected. */
428
+ transition:
429
+ opacity 0ms ease,
430
+ border-radius 150ms ease;
431
+ }
432
+ }
433
+ .text-content::before {
434
+ opacity: 0.06;
435
+ transition:
436
+ opacity 0ms ease,
437
+ border-radius 150ms ease;
438
+ }
439
+ }
440
+ &.disabled .text-content {
441
+ color: var(--color-text-disabled);
442
+ }
443
+ &::after {
444
+ content: '';
445
+ position: absolute;
446
+ top: 0px;
447
+ right: 1rem;
448
+ left: 1rem;
449
+ border-top: solid 1px color-mix(in oklch, transparent, var(--color-text) 6%);
450
+ }
451
+ &:first-child {
452
+ &::after {
453
+ content: none;
454
+ }
455
+ &::before {
456
+ top: var(--border-inset);
457
+ }
458
+ :global(> .ripple) {
459
+ top: var(--border-inset) !important;
460
+ }
461
+ a,
462
+ button,
463
+ label {
464
+ padding-top: calc(var(--border-inset, 0px) + 0.25rem);
465
+ &::before,
466
+ &::after {
467
+ top: var(--border-inset);
468
+ }
469
+ }
470
+ }
471
+ &:last-child {
472
+ &::before {
473
+ bottom: var(--border-inset);
474
+ }
475
+ :global(> .ripple) {
476
+ bottom: var(--border-inset) !important;
477
+ }
478
+ a,
479
+ button,
480
+ label {
481
+ padding-bottom: calc(var(--border-inset, 0px) + 0.25rem);
482
+ &::before,
483
+ &::after {
484
+ bottom: var(--border-inset);
485
+ }
486
+ }
487
+ }
488
+ &.dense {
489
+ --_radius: var(--radius-lg);
490
+ min-height: 2.5rem;
491
+ a,
492
+ button,
493
+ label {
494
+ padding-top: 0;
495
+ padding-bottom: 0;
496
+ padding-right: 1rem;
497
+ padding-left: calc(1rem + ((var(--level) - 1) * 1rem));
498
+ }
499
+ &:first-child {
500
+ a,
501
+ button,
502
+ label {
503
+ padding-top: calc(var(--border-inset, 0px));
504
+ }
505
+ }
506
+ &:last-child {
507
+ a,
508
+ button,
509
+ label {
510
+ padding-bottom: calc(var(--border-inset, 0px));
511
+ }
512
+ }
513
+ }
514
+ &.comfortable {
515
+ --_radius: var(--radius-xl);
516
+ min-height: 3.5rem;
517
+ a,
518
+ button,
519
+ label {
520
+ padding-top: 0.5rem;
521
+ padding-bottom: 0.5rem;
522
+ padding-left: calc(2rem + var(--list-pad-x, 0px));
523
+ padding-right: calc(
524
+ 2rem + var(--list-pad-x, 0px) + ((var(--level) - 1) * 1.5rem)
525
+ );
526
+ }
527
+ &:first-child {
528
+ a,
529
+ button,
530
+ label {
531
+ padding-top: calc(var(--border-inset, 0px) + 0.5rem);
532
+ }
533
+ }
534
+ &:last-child {
535
+ a,
536
+ button,
537
+ label {
538
+ padding-bottom: calc(var(--border-inset, 0px) + 0.5rem);
539
+ }
540
+ }
541
+ }
542
+ &.disabled {
543
+ cursor: not-allowed;
544
+ a,
545
+ button,
546
+ label {
547
+ cursor: not-allowed;
548
+ color: var(--color-text-disabled);
549
+ }
550
+ }
551
+ &.is-loading {
552
+ /* Busy while the onclick promise resolves — the inner button stops
553
+ taking pointer events (see &[aria-busy] below), so set the row cursor
554
+ here so the whole row reads as not-interactive. */
555
+ cursor: not-allowed;
556
+ }
557
+ &:not(.disabled) {
558
+ a,
559
+ button,
560
+ label {
561
+ &:hover:not(:disabled):not([aria-disabled='true']):not([aria-busy='true']) {
562
+ &::before {
563
+ opacity: 0.06;
564
+ transition:
565
+ opacity 0ms ease,
566
+ border-radius 150ms ease;
567
+ }
568
+ }
569
+ }
570
+ }
571
+ }
572
+ /* --- Adjacent highlighted rows merge into one block --- */
573
+ /* When two neighbouring rows are both "highlighted" — active, or hovered
574
+ while enabled and interactive — square off the corners where they meet so
575
+ the pair reads as one continuous selection instead of two rounded pills.
576
+ The border-radius transition on the highlight ::before animates it.
577
+
578
+ The sibling/:has parts are wrapped in :global() because ListItem renders a
579
+ single <li>; without it Svelte prunes these as "unused" (it can't see a
580
+ sibling .list-item in this component's own template). The `.text` guard
581
+ skips non-interactive text rows in the hover case (no highlight to merge). */
582
+ :global(.list-item.active:has(+ .list-item.active))
583
+ :is(a, button, label, .text-content)::before,
584
+ :global(
585
+ .list-item.active:has(+ .list-item:hover:not(.disabled):not(.text):not(.is-loading))
586
+ )
587
+ :is(a, button, label, .text-content)::before,
588
+ :global(
589
+ .list-item:hover:not(.disabled):not(.text):not(.is-loading):has(+ .list-item.active)
590
+ )
591
+ :is(a, button, label, .text-content)::before {
592
+ border-bottom-left-radius: 0;
593
+ border-bottom-right-radius: 0;
594
+ }
595
+ :global(.list-item.active + .list-item.active)
596
+ :is(a, button, label, .text-content)::before,
597
+ :global(.list-item:hover:not(.disabled):not(.text):not(.is-loading) + .list-item.active)
598
+ :is(a, button, label, .text-content)::before,
599
+ :global(.list-item.active + .list-item:hover:not(.disabled):not(.text):not(.is-loading))
600
+ :is(a, button, label, .text-content)::before {
601
+ border-top-left-radius: 0;
602
+ border-top-right-radius: 0;
603
+ }
604
+
605
+ .spacer {
606
+ flex: 1;
607
+ min-width: 1.5rem;
608
+ }
609
+ /* The native inputs are visually hidden but remain the real, focusable
610
+ * controls (the <label> toggles them, List delegates off their change
611
+ * event). The adjacent <Checkbox>/<Radio> render the visual state. */
612
+ input[type='radio'],
613
+ input[type='checkbox'] {
614
+ opacity: 0;
615
+ position: absolute;
616
+ width: 1px;
617
+ height: 1px;
618
+ margin: 0;
619
+ pointer-events: none;
620
+ }
621
+ .control {
622
+ display: inline-flex;
623
+ align-items: center;
624
+ flex-shrink: 0;
625
+ pointer-events: none;
626
+ }
627
+ /* Keyboard focus ring, driven by the hidden native input's focus state */
628
+ label:has(input:focus-visible) .control :global(.indicator-wrapper) {
629
+ box-shadow: 0 0 0 2px var(--color-border-active);
630
+ border-radius: 50%;
631
+ }
632
+ /* Same ring for toggle mode, following the Toggle's pill-shaped track */
633
+ label:has(input:focus-visible) .control :global(.toggle .track) {
634
+ box-shadow: 0 0 0 2px var(--color-border-active);
635
+ }
636
+ .text-content {
637
+ position: relative;
638
+ flex: 1;
639
+ /* Match the row's full height so the active background fills it (see the
640
+ * align-self note on a/button/label). */
641
+ align-self: stretch;
642
+ padding-top: 0.25rem;
643
+ padding-bottom: 0.25rem;
644
+ padding-right: calc(1.5rem + var(--list-pad-x, 0px));
645
+ padding-left: calc(1.5rem + var(--list-pad-x, 0px) + ((var(--level) - 1) * 1rem));
646
+ display: flex;
647
+ align-items: center;
648
+ color: var(--color-text);
649
+
650
+ &::before {
651
+ content: '';
652
+ opacity: 0;
653
+ position: absolute;
654
+ top: 1px;
655
+ right: var(--border-inset);
656
+ bottom: 1px;
657
+ left: calc(var(--border-inset) + ((var(--level) - 1) * 1rem));
658
+ border-radius: calc(var(--_radius) - var(--border-inset));
659
+ @supports (corner-shape: squircle) {
660
+ corner-shape: squircle;
661
+ border-radius: calc(
662
+ (var(--_radius) - var(--border-inset)) * var(--squircle-ratio, 2)
663
+ );
664
+ }
665
+ background-color: var(--color-text);
666
+ transition:
667
+ opacity 300ms ease,
668
+ border-radius 150ms ease;
669
+ z-index: 0;
670
+ }
671
+
672
+ li.dense & {
673
+ padding-top: 0;
674
+ padding-bottom: 0;
675
+ padding-right: 1rem;
676
+ padding-left: calc(1rem + ((var(--level) - 1) * 1rem));
677
+ }
678
+ li:first-child & {
679
+ padding-top: calc(var(--border-inset, 0px) + 0.25rem);
680
+ }
681
+ li:last-child & {
682
+ padding-bottom: calc(var(--border-inset, 0px) + 0.25rem);
683
+ }
684
+ }
685
+
686
+ a,
687
+ button,
688
+ label {
689
+ flex: 1;
690
+ padding-top: 0.25rem;
691
+ padding-bottom: 0.25rem;
692
+ padding-right: calc(1.5rem + var(--list-pad-x, 0px));
693
+ padding-left: calc(1.5rem + var(--list-pad-x, 0px) + ((var(--level) - 1) * 1rem));
694
+ margin: 0;
695
+ border: none;
696
+ /* Establish a containing block at rest so the ::before background is
697
+ * always positioned against this element. The :active press applies a
698
+ * translateZ, and a transformed element becomes the containing block for
699
+ * its absolutely-positioned descendants — without this, the ::before's
700
+ * containing block would switch from the <li> to here only while pressed,
701
+ * snapping its size on mousedown/up. Being relative up front keeps it
702
+ * stable, so the press just smoothly scales the background with the
703
+ * content via the list's `perspective`. */
704
+ position: relative;
705
+ display: flex;
706
+ align-items: center;
707
+ /* Fill the full list-item width so the hover/active background spans
708
+ * the row uniformly with the ripple — previously `max-content` made
709
+ * the hover-bg only as wide as the text, producing a tight inner
710
+ * highlight that fought the wider ripple on click. */
711
+ width: 100%;
712
+ /* Fill the row's full height. `height: 100%` can't be used here: the
713
+ * <li>'s height comes from `min-height` (e.g. dense mode), which is
714
+ * indefinite for percentage resolution, so the percentage collapses to
715
+ * the element's intrinsic height — shorter than the row whenever padding
716
+ * is small (dense). align-self stretches to the flex line's cross size,
717
+ * which does honour min-height, so the ::before background (and the press
718
+ * scale that rides on it) always matches the full row. */
719
+ align-self: stretch;
720
+ cursor: pointer;
721
+ color: var(--color-text);
722
+ background-color: transparent;
723
+ text-decoration: none;
724
+ box-shadow: none;
725
+ transition: translate 200ms ease;
726
+
727
+ &:active:not(:disabled):not([aria-disabled='true']):not([aria-busy='true']) {
728
+ translate: 0px 1px clamp(-10px, calc(0.2em - 12px), -2px);
729
+ }
730
+ /* Busy while the onclick promise is in flight: block pointer interaction
731
+ so a re-click can't re-press (:active) — the ripple is gated by its
732
+ `enabled` flag, and hover by the :not([aria-busy]) guards above. */
733
+ &[aria-busy='true'] {
734
+ pointer-events: none;
735
+ }
736
+ &::before {
737
+ content: '';
738
+ opacity: 0;
739
+ position: absolute;
740
+ top: 1px;
741
+ right: var(--border-inset);
742
+ bottom: 1px;
743
+ left: calc(var(--border-inset) + ((var(--level) - 1) * 1rem));
744
+ border-radius: calc(var(--_radius) - var(--border-inset));
745
+ @supports (corner-shape: squircle) {
746
+ corner-shape: squircle;
747
+ border-radius: calc(
748
+ (var(--_radius) - var(--border-inset)) * var(--squircle-ratio, 2)
749
+ );
750
+ }
751
+ background-color: var(--color-text);
752
+ transition:
753
+ opacity 300ms ease,
754
+ border-radius 150ms ease;
755
+ }
756
+ }
757
+ a[aria-disabled='true'] {
758
+ color: var(--color-text-disabled);
759
+ cursor: auto;
760
+ }
761
+ button,
762
+ label {
763
+ &:disabled {
764
+ color: var(--color-text-disabled);
765
+ cursor: auto;
766
+ }
767
+ }
768
+ button {
769
+ &:focus-visible {
770
+ &::after {
771
+ content: '';
772
+ position: absolute;
773
+ top: 1px;
774
+ right: var(--border-inset);
775
+ bottom: 1px;
776
+ left: calc(var(--border-inset) + ((var(--level) - 1) * 1rem));
777
+ border-radius: calc(var(--_radius) - var(--border-inset));
778
+ @supports (corner-shape: squircle) {
779
+ corner-shape: squircle;
780
+ border-radius: calc(
781
+ (var(--_radius) - var(--border-inset)) * var(--squircle-ratio, 2)
782
+ );
783
+ }
784
+ border: solid 1px var(--color-border-active);
785
+ }
786
+ }
787
+ }
788
+
789
+ .loading-icon {
790
+ position: relative;
791
+ display: flex;
792
+ justify-content: center;
793
+ align-items: center;
794
+ width: 1.5em;
795
+ margin-left: -0.25em;
796
+ margin-right: 0.5em;
797
+ height: 100%;
798
+ flex-shrink: 0;
799
+ flex-grow: 0;
800
+ :global(.logo) {
801
+ display: block;
802
+ width: 100%;
803
+ height: auto;
804
+ aspect-ratio: 1;
805
+ flex-shrink: 0;
806
+ flex-grow: 0;
807
+ }
808
+ /* Spinner and success check occupy the same spot so they can crossfade
809
+ during the handoff without nudging the label. */
810
+ .icon-layer {
811
+ position: absolute;
812
+ inset: 0;
813
+ display: flex;
814
+ align-items: center;
815
+ justify-content: center;
816
+ }
817
+ .check {
818
+ display: block;
819
+ /* The tick only spans the middle of its viewBox, so fill the slot
820
+ (paired with the tightened viewBox) to size it like the spinner. The
821
+ slot width is fixed and the layer is absolutely positioned, so this
822
+ never shifts layout. */
823
+ width: 1.25em;
824
+ height: 1.25em;
825
+ }
826
+ .check path {
827
+ /* Dash length >= the tick's path length; checkIn() rides --check-draw
828
+ from 24 (hidden) down to 0 (drawn). The 0 fallback keeps it drawn
829
+ once the transition's inline style is gone. */
830
+ stroke-dasharray: 24;
831
+ stroke-dashoffset: var(--check-draw, 0);
832
+ }
833
+ }
834
+ </style>