@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,698 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+
4
+ /** A single tab descriptor in the `tabs` array. */
5
+ export interface TabItem {
6
+ /** The label text shown in the tab button. */
7
+ label: string;
8
+ /** An optional badge (count or short string) shown after the label. */
9
+ badge?: string | number;
10
+ /** Whether this individual tab is disabled. */
11
+ disabled?: boolean;
12
+ /** The panel content for this tab. When omitted, the component's
13
+ children are used as the panel instead (gate them yourself with the
14
+ bound `tab` index). */
15
+ content?: Snippet;
16
+ }
17
+
18
+ /** How the panel content animates when the active tab changes. */
19
+ export type TabsTransition = 'none' | 'fade' | 'slide';
20
+ </script>
21
+
22
+ <script lang="ts">
23
+ import { untrack } from 'svelte';
24
+ import { ripple } from '@delightstack/utilities';
25
+
26
+ const propId = $props.id();
27
+
28
+ let {
29
+ /** The index of the active tab (bindable). */
30
+ tab = $bindable(0),
31
+
32
+ /** The tabs to render, in order. The array index is the tab's value. */
33
+ tabs = [] as TabItem[],
34
+
35
+ /** Use pill-shaped tab buttons. */
36
+ pills = false,
37
+
38
+ /** Use a boxed (segmented-control) tab style. */
39
+ boxed = false,
40
+
41
+ /** The orientation of the tab list. */
42
+ orientation = 'horizontal' as 'horizontal' | 'vertical',
43
+
44
+ /** The size of the tabs. 0=small, 1=default, 2=medium, 3=large. */
45
+ size = '1' as '0' | '1' | '2' | '3',
46
+
47
+ /** Stretch tab buttons to fill the available width. */
48
+ full_width = false,
49
+
50
+ /** Disable every tab. */
51
+ disabled = false,
52
+
53
+ /** How the panel content animates between tabs. */
54
+ transition = 'none' as TabsTransition,
55
+
56
+ /** Show a skeleton loading state in place of the tab list. */
57
+ skeleton = false,
58
+
59
+ /** Number of skeleton tab placeholders. */
60
+ skeleton_count = 3,
61
+
62
+ /** Called when the active tab changes. */
63
+ onchange = undefined as ((detail: { tab: number }) => void) | undefined,
64
+
65
+ /** The ID of the element. */
66
+ id = propId,
67
+
68
+ /** Additional class name(s). */
69
+ class: class_name = '',
70
+
71
+ /** Panel content used when a tab has no `content` snippet. Receives the
72
+ active tab index and a `select` helper. */
73
+ children = undefined as
74
+ | undefined
75
+ | Snippet<[{ tab: number; select: (i: number) => void }]>,
76
+ } = $props();
77
+
78
+ /* ------------------------------------------------------------------ */
79
+ /* Active tab + selection */
80
+ /* ------------------------------------------------------------------ */
81
+ function select(i: number) {
82
+ const item = tabs[i];
83
+ if (disabled || !item || item.disabled || i === tab) return;
84
+ tab = i;
85
+ onchange?.({ tab: i });
86
+ }
87
+
88
+ /** Track navigation direction (for the slide transition). */
89
+ let prevTab = tab;
90
+ let direction = $state(1);
91
+ $effect(() => {
92
+ const next = tab;
93
+ untrack(() => {
94
+ if (next !== prevTab) {
95
+ direction = next > prevTab ? 1 : -1;
96
+ prevTab = next;
97
+ }
98
+ });
99
+ });
100
+
101
+ const activeContent = $derived(tabs[tab]?.content);
102
+
103
+ /* ------------------------------------------------------------------ */
104
+ /* Sliding indicator */
105
+ /* ------------------------------------------------------------------ */
106
+ let listEl = $state<HTMLElement | undefined>(undefined);
107
+ let tabEls = $state<HTMLElement[]>([]);
108
+ let indicatorStyle = $state('opacity: 0;');
109
+
110
+ function measure() {
111
+ const el = tabEls[tab];
112
+ if (!listEl || !el) {
113
+ indicatorStyle = 'opacity: 0;';
114
+ return;
115
+ }
116
+ if (boxed) {
117
+ // The thumb sits exactly on the active tab's box, so the list's padding
118
+ // shows as an equal gutter on all four sides (no top/left mismatch).
119
+ indicatorStyle =
120
+ `transform: translate(${el.offsetLeft}px, ${el.offsetTop}px);` +
121
+ ` width: ${el.offsetWidth}px; height: ${el.offsetHeight}px; opacity: 1;`;
122
+ } else if (orientation === 'vertical') {
123
+ indicatorStyle = `transform: translateY(${el.offsetTop}px); height: ${el.offsetHeight}px; opacity: 1;`;
124
+ } else {
125
+ indicatorStyle = `transform: translateX(${el.offsetLeft}px); width: ${el.offsetWidth}px; opacity: 1;`;
126
+ }
127
+ }
128
+
129
+ // Re-measure whenever anything that affects geometry changes. Effects run
130
+ // after the DOM updates, so offsets are already settled — no rAF needed.
131
+ $effect(() => {
132
+ // Touch every geometry input so the effect re-runs when any of them change
133
+ // (measure() reads tab/orientation/variant, but not these layout inputs).
134
+ const _deps = [tabs.length, pills, full_width, size, skeleton];
135
+ void _deps;
136
+ if (!skeleton) measure();
137
+ });
138
+
139
+ // Late layout shifts (font load, container resize, full-width reflow) don't
140
+ // touch any of the tracked state above, so observe the list directly.
141
+ $effect(() => {
142
+ if (!listEl || skeleton) return;
143
+ const ro = new ResizeObserver(() => measure());
144
+ ro.observe(listEl);
145
+ for (const el of tabEls) if (el) ro.observe(el);
146
+ return () => ro.disconnect();
147
+ });
148
+
149
+ /* ------------------------------------------------------------------ */
150
+ /* Keyboard navigation (roving focus, auto-activation) */
151
+ /* ------------------------------------------------------------------ */
152
+ function enabledStep(from: number, step: number): number {
153
+ const n = tabs.length;
154
+ for (let i = 1; i <= n; i++) {
155
+ const idx = (from + step * i + n * i) % n;
156
+ if (!tabs[idx]?.disabled) return idx;
157
+ }
158
+ return from;
159
+ }
160
+
161
+ function firstEnabled(): number {
162
+ const i = tabs.findIndex((t) => !t?.disabled);
163
+ return i === -1 ? 0 : i;
164
+ }
165
+
166
+ function lastEnabled(): number {
167
+ for (let i = tabs.length - 1; i >= 0; i--) if (!tabs[i]?.disabled) return i;
168
+ return tabs.length - 1;
169
+ }
170
+
171
+ function focusIndex(i: number) {
172
+ const el = tabEls[i];
173
+ if (el) el.focus();
174
+ select(i);
175
+ }
176
+
177
+ function onKeyDown(e: KeyboardEvent, i: number) {
178
+ if (e.key === 'Enter' || e.key === ' ') {
179
+ e.preventDefault();
180
+ select(i);
181
+ return;
182
+ }
183
+ const vertical = orientation === 'vertical';
184
+ const next = vertical ? 'ArrowDown' : 'ArrowRight';
185
+ const prev = vertical ? 'ArrowUp' : 'ArrowLeft';
186
+ if (e.key === next) {
187
+ e.preventDefault();
188
+ focusIndex(enabledStep(i, 1));
189
+ } else if (e.key === prev) {
190
+ e.preventDefault();
191
+ focusIndex(enabledStep(i, -1));
192
+ } else if (e.key === 'Home') {
193
+ e.preventDefault();
194
+ focusIndex(firstEnabled());
195
+ } else if (e.key === 'End') {
196
+ e.preventDefault();
197
+ focusIndex(lastEnabled());
198
+ }
199
+ }
200
+
201
+ /* ------------------------------------------------------------------ */
202
+ /* Transitions */
203
+ /* ------------------------------------------------------------------ */
204
+ let reduce_motion = $state(false);
205
+ $effect(() => {
206
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
207
+ reduce_motion = mq.matches;
208
+ const on = () => (reduce_motion = mq.matches);
209
+ mq.addEventListener('change', on);
210
+ return () => mq.removeEventListener('change', on);
211
+ });
212
+
213
+ const effTransition = $derived<TabsTransition>(reduce_motion ? 'none' : transition);
214
+
215
+ const DURATION = 300;
216
+ const EASE = (t: number) => 1 - Math.pow(1 - t, 3); // cubic-out
217
+ /** Slide travel distance (rem). Bold enough to read as a real slide. */
218
+ const SLIDE = 4.5;
219
+
220
+ function panelIn(_node: HTMLElement) {
221
+ if (effTransition === 'fade') {
222
+ return { duration: DURATION, easing: EASE, css: (t: number) => `opacity: ${t}` };
223
+ }
224
+ // slide: incoming panel flies in from the direction of travel
225
+ const d = direction;
226
+ return {
227
+ duration: DURATION,
228
+ easing: EASE,
229
+ css: (t: number) =>
230
+ `opacity: ${t}; transform: translateX(${(1 - t) * d * SLIDE}rem)`,
231
+ };
232
+ }
233
+
234
+ function panelOut(_node: HTMLElement) {
235
+ if (effTransition === 'fade') {
236
+ return { duration: DURATION, easing: EASE, css: (t: number) => `opacity: ${t}` };
237
+ }
238
+ // slide: outgoing panel exits opposite the direction of travel
239
+ const d = direction;
240
+ return {
241
+ duration: DURATION,
242
+ easing: EASE,
243
+ css: (t: number) =>
244
+ `opacity: ${t}; transform: translateX(${(1 - t) * -d * SLIDE}rem)`,
245
+ };
246
+ }
247
+
248
+ /* ------------------------------------------------------------------ */
249
+ const sizeMap: Record<string, string> = {
250
+ '0': 'var(--text-sm, 0.815rem)',
251
+ '1': 'var(--text-base, 1rem)',
252
+ '2': 'var(--text-lg, 1.1rem)',
253
+ '3': 'var(--text-xl, 1.25rem)',
254
+ };
255
+
256
+ // Pseudo-random skeleton tab widths (em) — real labels vary, so should these.
257
+ const skeletonWidths = [5, 6.5, 4.25, 5.75];
258
+
259
+ const hasPanel = $derived(!!activeContent || !!children);
260
+ const panelId = `tabpanel-${id}`;
261
+ </script>
262
+
263
+ <div
264
+ {id}
265
+ class={['tabs', class_name].filter(Boolean).join(' ')}
266
+ class:pills
267
+ class:boxed
268
+ class:vertical={orientation === 'vertical'}
269
+ class:full-width={full_width}
270
+ class:disabled
271
+ style:font-size={sizeMap[size] ?? sizeMap['1']}>
272
+ {#if skeleton}
273
+ <div class="list skeleton" role="tablist" aria-hidden="true">
274
+ {#each { length: skeleton_count } as _, i}
275
+ <div
276
+ class="skeleton-tab"
277
+ style:width="{skeletonWidths[i % skeletonWidths.length]}em"
278
+ style:--shimmer-delay="{i * 120}ms">
279
+ </div>
280
+ {/each}
281
+ </div>
282
+ {:else}
283
+ <div class="list" role="tablist" aria-orientation={orientation} bind:this={listEl}>
284
+ <div class="indicator" style={indicatorStyle}></div>
285
+ {#each tabs as t, i (i)}
286
+ {@const isDisabled = disabled || !!t.disabled}
287
+ <button
288
+ type="button"
289
+ role="tab"
290
+ class="tab"
291
+ class:active={tab === i}
292
+ class:disabled={isDisabled}
293
+ aria-selected={tab === i}
294
+ aria-disabled={isDisabled || undefined}
295
+ aria-controls={hasPanel ? panelId : undefined}
296
+ id="tab-{id}-{i}"
297
+ tabindex={tab === i ? 0 : -1}
298
+ bind:this={tabEls[i]}
299
+ onclick={() => select(i)}
300
+ onkeydown={(e) => onKeyDown(e, i)}
301
+ {@attach ripple({ enabled: !isDisabled, zIndex: 0 })}>
302
+ <span class="label">{t.label}</span>
303
+ {#if t.badge !== undefined}
304
+ <span class="badge">{t.badge}</span>
305
+ {/if}
306
+ </button>
307
+ {/each}
308
+ </div>
309
+
310
+ {#if hasPanel}
311
+ <div class="panels" class:animated={effTransition !== 'none'}>
312
+ {#if effTransition === 'none'}
313
+ <div
314
+ class="panel"
315
+ role="tabpanel"
316
+ id={panelId}
317
+ aria-labelledby="tab-{id}-{tab}"
318
+ tabindex="0">
319
+ {#if activeContent}
320
+ {@render activeContent()}
321
+ {:else if children}
322
+ {@render children({ tab, select })}
323
+ {/if}
324
+ </div>
325
+ {:else}
326
+ {#key tab}
327
+ <div
328
+ class="panel"
329
+ role="tabpanel"
330
+ id={panelId}
331
+ aria-labelledby="tab-{id}-{tab}"
332
+ tabindex="0"
333
+ in:panelIn
334
+ out:panelOut>
335
+ {#if activeContent}
336
+ {@render activeContent()}
337
+ {:else if children}
338
+ {@render children({ tab, select })}
339
+ {/if}
340
+ </div>
341
+ {/key}
342
+ {/if}
343
+ </div>
344
+ {/if}
345
+ {/if}
346
+ </div>
347
+
348
+ <style>
349
+ /* ========== Container ========== */
350
+ .tabs {
351
+ display: flex;
352
+ flex-direction: column;
353
+ width: 100%;
354
+ min-width: 0;
355
+
356
+ &.vertical {
357
+ flex-direction: row;
358
+ align-items: flex-start;
359
+ }
360
+
361
+ &.disabled {
362
+ opacity: 0.6;
363
+ pointer-events: none;
364
+ }
365
+ }
366
+
367
+ /* ========== Tab list ========== */
368
+ .list {
369
+ display: flex;
370
+ position: relative;
371
+ gap: 0;
372
+ flex-shrink: 0;
373
+ border-bottom: 1px solid var(--color-border, #e0e0e0);
374
+
375
+ .tabs.vertical & {
376
+ flex-direction: column;
377
+ border-bottom: none;
378
+ border-right: 1px solid var(--color-border, #e0e0e0);
379
+ }
380
+
381
+ .tabs.pills & {
382
+ border-bottom: none;
383
+ gap: 0.3rem;
384
+ }
385
+
386
+ .tabs.pills.vertical & {
387
+ border-right: none;
388
+ }
389
+
390
+ .tabs.boxed & {
391
+ background: var(--color-bg-muted, #f1f1f1);
392
+ border: 1px solid var(--color-border, #e0e0e0);
393
+ border-radius: var(--radius-lg, 10px);
394
+ padding: 0.3rem;
395
+ gap: 0;
396
+ @supports (corner-shape: squircle) {
397
+ corner-shape: squircle;
398
+ border-radius: calc(var(--radius-lg, 10px) * var(--squircle-ratio, 2));
399
+ }
400
+ }
401
+
402
+ .tabs.full-width & {
403
+ width: 100%;
404
+ }
405
+ .tabs.full-width & > .tab {
406
+ flex: 1;
407
+ }
408
+ }
409
+
410
+ /* ========== Sliding indicator ========== */
411
+ .indicator {
412
+ position: absolute;
413
+ bottom: -1px;
414
+ left: 0;
415
+ height: 2px;
416
+ background: var(--color-action, #1976d2);
417
+ border-radius: var(--radius-full, 1e5px);
418
+ pointer-events: none;
419
+ z-index: 1;
420
+ opacity: 0;
421
+ transition:
422
+ transform 260ms var(--ease-spring, cubic-bezier(0.34, 1.4, 0.64, 1)),
423
+ width 260ms var(--ease-spring, cubic-bezier(0.34, 1.4, 0.64, 1)),
424
+ height 260ms var(--ease-spring, cubic-bezier(0.34, 1.4, 0.64, 1)),
425
+ opacity 150ms ease;
426
+
427
+ .tabs.vertical & {
428
+ bottom: auto;
429
+ left: auto;
430
+ right: -1px;
431
+ top: 0;
432
+ width: 2px;
433
+ height: auto;
434
+ }
435
+
436
+ .tabs.pills & {
437
+ display: none;
438
+ }
439
+
440
+ /* Boxed: the indicator becomes the active "thumb" — an elevated surface
441
+ that glides between options. measure() gives it the active tab's exact
442
+ box (translate x/y + width/height), so the list padding reads as an
443
+ equal gutter on every side. */
444
+ .tabs.boxed & {
445
+ top: 0;
446
+ bottom: auto;
447
+ left: 0;
448
+ height: auto;
449
+ background: var(--color-surface, #fff);
450
+ border-radius: calc(var(--radius-lg, 10px) - 0.2rem);
451
+ box-shadow:
452
+ 0 1px 2px rgb(0 0 0 / 0.06),
453
+ 0 2px 6px rgb(0 0 0 / 0.08);
454
+ @supports (corner-shape: squircle) {
455
+ corner-shape: squircle;
456
+ border-radius: calc((var(--radius-lg, 10px) - 0.2rem) * var(--squircle-ratio, 2));
457
+ }
458
+ }
459
+ }
460
+
461
+ /* ========== Tab button ========== */
462
+ .tab {
463
+ display: inline-flex;
464
+ align-items: center;
465
+ justify-content: center;
466
+ gap: 0.5em;
467
+ position: relative;
468
+ z-index: 2;
469
+ flex-shrink: 0;
470
+ background: transparent;
471
+ border: none;
472
+ cursor: pointer;
473
+ padding: 0.7em 1.05em;
474
+ margin: 0;
475
+ font-size: inherit;
476
+ font-family: inherit;
477
+ font-weight: 500;
478
+ line-height: 1.2;
479
+ color: var(--color-text-muted, #666);
480
+ white-space: nowrap;
481
+ outline: none;
482
+ border-radius: var(--radius-md, 5px);
483
+ -webkit-tap-highlight-color: transparent;
484
+ @supports (corner-shape: squircle) {
485
+ corner-shape: squircle;
486
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
487
+ }
488
+ /* OUT transition: colours/background snap in on hover, ease back out;
489
+ the press scale always eases (both directions) so it feels physical. */
490
+ transition:
491
+ color 220ms ease,
492
+ background-color 220ms ease,
493
+ scale 160ms ease;
494
+
495
+ &:hover:not(.disabled):not(.active) {
496
+ color: var(--color-text, #222);
497
+ background: rgb(from var(--color-text, #333) r g b / 0.06);
498
+ /* snap the colour/background in; keep the scale easing */
499
+ transition: scale 160ms ease;
500
+ }
501
+
502
+ &:active:not(.disabled) {
503
+ scale: 0.9;
504
+ }
505
+
506
+ &:focus-visible {
507
+ box-shadow: inset 0 0 0 2px var(--color-action, #1976d2);
508
+ border-radius: var(--radius-md, 5px);
509
+ @supports (corner-shape: squircle) {
510
+ corner-shape: squircle;
511
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
512
+ }
513
+ }
514
+
515
+ &.active {
516
+ color: var(--color-action, #1976d2);
517
+ }
518
+
519
+ &.disabled {
520
+ opacity: 0.45;
521
+ cursor: not-allowed;
522
+ }
523
+
524
+ /* ---- Pills (variant class lives on the container) ---- */
525
+ .tabs.pills & {
526
+ border-radius: var(--radius-full, 1e5px);
527
+ padding: 0.5em 1.1em;
528
+
529
+ &.active {
530
+ background: var(--color-action, #1976d2);
531
+ color: var(--color-action-text, #fff);
532
+ }
533
+ }
534
+
535
+ /* ---- Boxed (text rides above the gliding thumb) ---- */
536
+ .tabs.boxed & {
537
+ border-radius: calc(var(--radius-lg, 10px) - 0.2rem);
538
+ padding: 0.5em 1.1em;
539
+ @supports (corner-shape: squircle) {
540
+ corner-shape: squircle;
541
+ border-radius: calc((var(--radius-lg, 10px) - 0.2rem) * var(--squircle-ratio, 2));
542
+ }
543
+ &.active {
544
+ color: var(--color-text-active, var(--color-text, #222));
545
+ }
546
+ }
547
+ }
548
+
549
+ .label {
550
+ position: relative;
551
+ z-index: 1;
552
+ pointer-events: none;
553
+ }
554
+
555
+ /* ========== Badge ========== */
556
+ .badge {
557
+ display: inline-flex;
558
+ align-items: center;
559
+ justify-content: center;
560
+ background: var(--color-action, #1976d2);
561
+ color: var(--color-action-text, #fff);
562
+ border-radius: var(--radius-full, 1e5px);
563
+ font-size: 0.72em;
564
+ font-weight: 600;
565
+ line-height: 1;
566
+ padding: 0.2em 0.45em;
567
+ min-width: 1.5em;
568
+ min-height: 1.35em;
569
+ position: relative;
570
+ z-index: 1;
571
+ pointer-events: none;
572
+ transition:
573
+ background-color 220ms ease,
574
+ color 220ms ease;
575
+ }
576
+
577
+ /* Inactive tabs get a quieter, tinted badge; the active one inverts inside
578
+ pills so it stays legible on the filled background. */
579
+ .tab:not(.active) .badge {
580
+ background: rgb(from var(--color-text, #333) r g b / 0.1);
581
+ color: var(--color-text-muted, #666);
582
+ }
583
+ .tabs.pills .tab.active .badge {
584
+ background: var(--color-action-text, #fff);
585
+ color: var(--color-action, #1976d2);
586
+ }
587
+
588
+ /* ========== Panels ========== */
589
+ .panels {
590
+ min-width: 0;
591
+
592
+ .tabs.vertical & {
593
+ flex: 1;
594
+ padding-left: 1.25em;
595
+ }
596
+ }
597
+
598
+ /* When animated, stack incoming/outgoing panels in one grid cell so they
599
+ crossfade/slide over each other without a layout jump. */
600
+ .panels.animated {
601
+ display: grid;
602
+ overflow: hidden;
603
+ }
604
+ .panels.animated > .panel {
605
+ grid-area: 1 / 1;
606
+ }
607
+
608
+ .panel {
609
+ padding: 1.1em 0;
610
+ outline: none;
611
+
612
+ .tabs.vertical & {
613
+ padding-top: 0;
614
+ }
615
+
616
+ &:focus-visible {
617
+ outline: 2px solid var(--color-action, #1976d2);
618
+ outline-offset: 2px;
619
+ border-radius: var(--radius-md, 5px);
620
+ @supports (corner-shape: squircle) {
621
+ corner-shape: squircle;
622
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
623
+ }
624
+ }
625
+ }
626
+
627
+ /* ========== Skeleton ========== */
628
+ .list.skeleton {
629
+ pointer-events: none;
630
+ gap: 0.4em;
631
+ border-bottom-color: var(--color-border, #e0e0e0);
632
+
633
+ .tabs.full-width & > .skeleton-tab {
634
+ flex: 1;
635
+ }
636
+ }
637
+
638
+ .skeleton-tab {
639
+ /* Match a real tab's box exactly so toggling skeleton ↔ loaded never
640
+ shifts the row: line-height 1.2 (same as .tab) makes 1lh resolve to the
641
+ real line box instead of inheriting the page's larger line-height, and
642
+ 1.4em is the default tab's block padding (0.7em × 2). */
643
+ line-height: 1.2;
644
+ height: calc(1lh + 1.4em);
645
+ flex-shrink: 0;
646
+ border-radius: var(--radius-md, 5px);
647
+ position: relative;
648
+ overflow: hidden;
649
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
650
+ @supports (corner-shape: squircle) {
651
+ corner-shape: squircle;
652
+ border-radius: calc(var(--radius-md, 5px) * var(--squircle-ratio, 2));
653
+ }
654
+
655
+ &::after {
656
+ content: '';
657
+ position: absolute;
658
+ inset: 0;
659
+ transform: translateX(-100%);
660
+ background-image: linear-gradient(
661
+ 105deg,
662
+ transparent 25%,
663
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
664
+ transparent 75%
665
+ );
666
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
667
+ infinite;
668
+ animation-delay: var(--shimmer-delay, 0s);
669
+ }
670
+
671
+ .tabs.pills &,
672
+ .tabs.boxed & {
673
+ height: calc(1lh + 1em);
674
+ }
675
+ .tabs.pills & {
676
+ border-radius: var(--radius-full, 1e5px);
677
+ }
678
+ }
679
+
680
+ @keyframes -global-delight-skeleton-shimmer {
681
+ 0% {
682
+ transform: translateX(-100%);
683
+ }
684
+ 55%,
685
+ 100% {
686
+ transform: translateX(100%);
687
+ }
688
+ }
689
+
690
+ @media (prefers-reduced-motion: reduce) {
691
+ .skeleton-tab::after {
692
+ animation: none;
693
+ }
694
+ .indicator {
695
+ transition: opacity 150ms ease;
696
+ }
697
+ }
698
+ </style>