@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,19 @@
1
+ type $$ComponentProps = {
2
+ code: string;
3
+ language?: string;
4
+ filename?: string;
5
+ show_line_numbers?: boolean;
6
+ show_copy?: boolean;
7
+ start_line?: number;
8
+ highlight_lines?: number[];
9
+ diff?: boolean;
10
+ wrap?: boolean;
11
+ max_height?: string;
12
+ skeleton?: boolean;
13
+ id?: string;
14
+ class?: string;
15
+ };
16
+ declare const Code: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type Code = ReturnType<typeof Code>;
18
+ export default Code;
19
+ //# sourceMappingURL=Code.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Code.svelte.d.ts","sourceRoot":"","sources":["../../src/display/Code.svelte.ts"],"names":[],"mappings":"AAUC,KAAK,gBAAgB,GAAI;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,CAAC;CACf,CAAC;AAmYH,QAAA,MAAM,IAAI,sDAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
@@ -0,0 +1,686 @@
1
+ <script lang="ts">
2
+ const propId = $props.id();
3
+ let {
4
+ /** The URL of the "before" image */
5
+ before,
6
+
7
+ /** The URL of the "after" image */
8
+ after,
9
+
10
+ /** Alt text for the before image @default 'Before' */
11
+ before_alt = 'Before',
12
+
13
+ /** Alt text for the after image @default 'After' */
14
+ after_alt = 'After',
15
+
16
+ /** The divider position from 0 to 100 (percentage) */
17
+ position = $bindable(50),
18
+
19
+ /** Whether the comparison should be vertical instead of horizontal */
20
+ vertical = false,
21
+
22
+ /** Whether to show "Before" and "After" labels */
23
+ show_labels = true,
24
+
25
+ /** The text for the before label @default 'Before' */
26
+ label_before = 'Before',
27
+
28
+ /** The text for the after label @default 'After' */
29
+ label_after = 'After',
30
+
31
+ /** Whether to show a skeleton loading state */
32
+ skeleton = false,
33
+
34
+ /** Snap points the divider magnetically locks to (percentage values 0-100) */
35
+ snaps = [] as number[],
36
+
37
+ /** The ID of the component @defaults to a random ID */
38
+ id = propId,
39
+
40
+ /** Specifies a custom class name for the container element */
41
+ class: class_name = '',
42
+
43
+ /** Called when the position changes */
44
+ onchange = undefined as ((detail: { position: number }) => void) | undefined,
45
+ }: {
46
+ before: string;
47
+ after: string;
48
+ before_alt?: string;
49
+ after_alt?: string;
50
+ position?: number;
51
+ vertical?: boolean;
52
+ show_labels?: boolean;
53
+ label_before?: string;
54
+ label_after?: string;
55
+ skeleton?: boolean;
56
+ snaps?: number[];
57
+ id?: string;
58
+ class?: string;
59
+ onchange?: (detail: { position: number }) => void;
60
+ } = $props();
61
+
62
+ let container: HTMLElement | undefined = $state(undefined);
63
+ let dragging = $state(false);
64
+ /** Whether the pointer moved during the current press, to distinguish a drag
65
+ * (already positioned live) from a click that should position on release. */
66
+ let pointer_moved = false;
67
+ let overshoot_px = $state(0);
68
+ let last_pointer_coord = 0;
69
+ let force_overflow = $state(false);
70
+ let overflow_timer: ReturnType<typeof setTimeout> | undefined;
71
+ let snapped_to: number | null = null;
72
+
73
+ /** Capture radius (%) — how close the divider must come before a snap grabs
74
+ * it. Small so you can rest near a snap point without being pulled in. */
75
+ const SNAP_RADIUS = 4;
76
+ /** Hold/well radius (%) — once snapped, how far the divider may travel before
77
+ * it breaks free. Kept large relative to SNAP_RADIUS so the magnet feels
78
+ * strong and sticky with a satisfying release spring (hysteresis). Capped
79
+ * per-direction by {@link snapReach} so neighbouring wells never overlap. */
80
+ const SNAP_ESCAPE = 10;
81
+
82
+ /** How far the handle may stray from `snap` toward `dir` (+1 / -1) before it
83
+ * hands off, and the radius of that snap's gravity well in that direction.
84
+ * Capped at the midpoint to the nearest neighbour so adjacent wells meet
85
+ * cleanly at the midpoint instead of overlapping — an overlap lets the next
86
+ * snap grab the handle partway into its well, teleporting the divider. */
87
+ function snapReach(snap: number, dir: number): number {
88
+ let reach = SNAP_ESCAPE;
89
+ for (const other of snaps) {
90
+ if (Math.sign(other - snap) === dir) {
91
+ reach = Math.min(reach, Math.abs(other - snap) / 2);
92
+ }
93
+ }
94
+ return reach;
95
+ }
96
+
97
+ const clampedPosition = $derived(Math.min(100, Math.max(0, position)));
98
+
99
+ /** Clip-path for the "after" image based on orientation and position (includes overshoot) */
100
+ const afterClipPath = $derived(
101
+ vertical
102
+ ? `inset(calc(${clampedPosition}% + ${overshoot_px}px) 0 0 0)`
103
+ : `inset(0 0 0 calc(${clampedPosition}% + ${overshoot_px}px))`,
104
+ );
105
+
106
+ /** CSS for the divider position */
107
+ const dividerStyle = $derived(
108
+ vertical
109
+ ? `top: ${clampedPosition}%; left: 0; right: 0;`
110
+ : `left: ${clampedPosition}%; top: 0; bottom: 0;`,
111
+ );
112
+
113
+ const needs_visible_overflow = $derived(dragging || force_overflow);
114
+
115
+ function snapPosition(raw: number): number {
116
+ if (snaps.length === 0) return raw;
117
+
118
+ // Hysteresis: once locked to a snap, stay locked to *that* snap until the
119
+ // pointer moves past its (direction-aware) reach. Using snapReach instead
120
+ // of a flat SNAP_ESCAPE keeps the hold zone from overlapping the next
121
+ // snap's capture zone, so escaping one snap hands off to a free zone (or a
122
+ // clean snap-in) rather than teleporting straight into the neighbour.
123
+ if (snapped_to !== null) {
124
+ if (
125
+ Math.abs(raw - snapped_to) <= snapReach(snapped_to, Math.sign(raw - snapped_to))
126
+ ) {
127
+ return snapped_to;
128
+ }
129
+ snapped_to = null;
130
+ }
131
+
132
+ // Not locked to anything: capture the nearest snap within SNAP_RADIUS
133
+ // (never reaching past the handoff midpoint for tightly-spaced snaps).
134
+ let nearest_snap = -1;
135
+ let min_dist = Infinity;
136
+
137
+ for (const snap of snaps) {
138
+ const dist = Math.abs(raw - snap);
139
+ const capture = Math.min(SNAP_RADIUS, snapReach(snap, Math.sign(raw - snap)));
140
+ if (dist < min_dist && dist <= capture) {
141
+ min_dist = dist;
142
+ nearest_snap = snap;
143
+ }
144
+ }
145
+
146
+ if (nearest_snap >= 0) {
147
+ snapped_to = nearest_snap;
148
+ return nearest_snap;
149
+ }
150
+ return raw;
151
+ }
152
+
153
+ function updatePosition(clientX: number, clientY: number) {
154
+ if (!container) return;
155
+ const rect = container.getBoundingClientRect();
156
+ let raw: number;
157
+ if (vertical) {
158
+ raw = ((clientY - rect.top) / rect.height) * 100;
159
+ } else {
160
+ raw = ((clientX - rect.left) / rect.width) * 100;
161
+ }
162
+
163
+ let newPosition: number;
164
+ if (raw < 0) {
165
+ newPosition = 0;
166
+ } else if (raw > 100) {
167
+ newPosition = 100;
168
+ } else {
169
+ newPosition = Math.round(raw * 100) / 100;
170
+ newPosition = snapPosition(newPosition);
171
+ }
172
+
173
+ if (newPosition !== position) {
174
+ position = newPosition;
175
+ onchange?.({ position });
176
+ }
177
+ }
178
+
179
+ function updateOvershoot() {
180
+ if (!container) return;
181
+ const rect = container.getBoundingClientRect();
182
+ const dimension = vertical ? rect.height : rect.width;
183
+ const raw_pct = vertical
184
+ ? (last_pointer_coord - rect.top) / rect.height
185
+ : (last_pointer_coord - rect.left) / rect.width;
186
+
187
+ if (raw_pct < 0 || raw_pct > 1) {
188
+ // Edge rubber band (tanh bounded)
189
+ const overflow_px = (raw_pct < 0 ? raw_pct : raw_pct - 1) * dimension;
190
+ const max_shift = 24;
191
+ overshoot_px = max_shift * Math.tanh(overflow_px / 100);
192
+ } else if (snapped_to !== null) {
193
+ // Magnetic snap gravity — the handle clings to the snap and only
194
+ // reluctantly follows the pointer until the escape boundary, giving
195
+ // an obvious "gravity well" feel before it pops free. The well radius
196
+ // must equal the escape reach (snapReach): at t === 1 the eased curve
197
+ // returns the full radius, so the divider sits exactly under the
198
+ // pointer at the boundary — making the handoff seamless instead of a
199
+ // jump when the snap releases the handle.
200
+ const snapped_pct = snapped_to / 100;
201
+ const pull_px = (raw_pct - snapped_pct) * dimension;
202
+ const well_radius_px =
203
+ (snapReach(snapped_to, Math.sign(raw_pct - snapped_pct)) / 100) * dimension;
204
+
205
+ if (well_radius_px < 1) {
206
+ overshoot_px = 0;
207
+ } else {
208
+ const t = Math.min(1, Math.abs(pull_px) / well_radius_px);
209
+ const gravity = 0.16;
210
+ const eased = gravity * t + (1 - gravity) * t * t;
211
+ overshoot_px = Math.sign(pull_px) * eased * well_radius_px;
212
+ }
213
+ } else {
214
+ overshoot_px = 0;
215
+ }
216
+ }
217
+
218
+ function handlePointerDown(e: PointerEvent) {
219
+ if (skeleton) return;
220
+ e.preventDefault();
221
+ dragging = true;
222
+ pointer_moved = false;
223
+ last_pointer_coord = vertical ? e.clientY : e.clientX;
224
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
225
+ updatePosition(e.clientX, e.clientY);
226
+ updateOvershoot();
227
+ }
228
+
229
+ function handlePointerMove(e: PointerEvent) {
230
+ if (!dragging) return;
231
+ e.preventDefault();
232
+ pointer_moved = true;
233
+ updatePosition(e.clientX, e.clientY);
234
+ }
235
+
236
+ function handlePointerUp(e: PointerEvent) {
237
+ if (!dragging) return;
238
+ dragging = false;
239
+ snapped_to = null;
240
+ if (Math.abs(overshoot_px) > 0.5) {
241
+ force_overflow = true;
242
+ clearTimeout(overflow_timer);
243
+ overflow_timer = setTimeout(() => {
244
+ force_overflow = false;
245
+ }, 400);
246
+ }
247
+ overshoot_px = 0;
248
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
249
+ }
250
+
251
+ function handleContainerClick(e: MouseEvent) {
252
+ if (skeleton) return;
253
+ // The browser fires a click after every press+release. When that release
254
+ // concluded a drag, the divider is already positioned (and may have just
255
+ // snapped) — re-running updatePosition here would reset position to the
256
+ // drop point with snap state cleared, leaving the release spring to settle
257
+ // somewhere other than the snap it animated toward. Only handle genuine
258
+ // clicks (no drag movement); presses are already positioned in pointerdown.
259
+ if (pointer_moved) {
260
+ pointer_moved = false;
261
+ return;
262
+ }
263
+ updatePosition(e.clientX, e.clientY);
264
+ }
265
+
266
+ function handleKeyDown(e: KeyboardEvent) {
267
+ if (skeleton) return;
268
+ const step = e.shiftKey ? 10 : 1;
269
+ let newPosition = position;
270
+
271
+ switch (e.key) {
272
+ case 'ArrowLeft':
273
+ case 'ArrowUp':
274
+ e.preventDefault();
275
+ newPosition = position - step;
276
+ break;
277
+ case 'ArrowRight':
278
+ case 'ArrowDown':
279
+ e.preventDefault();
280
+ newPosition = position + step;
281
+ break;
282
+ case 'Home':
283
+ e.preventDefault();
284
+ newPosition = 0;
285
+ break;
286
+ case 'End':
287
+ e.preventDefault();
288
+ newPosition = 100;
289
+ break;
290
+ default:
291
+ return;
292
+ }
293
+
294
+ newPosition = Math.min(100, Math.max(0, newPosition));
295
+ if (newPosition !== position) {
296
+ position = newPosition;
297
+ onchange?.({ position });
298
+ }
299
+ }
300
+
301
+ // Window-level pointermove for overshoot (works even when pointer is outside container)
302
+ $effect(() => {
303
+ if (!dragging) return;
304
+
305
+ function onMove(e: PointerEvent) {
306
+ last_pointer_coord = vertical ? e.clientY : e.clientX;
307
+ updateOvershoot();
308
+ }
309
+
310
+ window.addEventListener('pointermove', onMove);
311
+ return () => window.removeEventListener('pointermove', onMove);
312
+ });
313
+ </script>
314
+
315
+ {#if skeleton}
316
+ <!-- Ghost of the real component: image well + divider/handle + label
317
+ pills, so the user sees "a comparison slider will appear here". -->
318
+ <div
319
+ class={['comparison', 'skeleton', class_name].filter(Boolean).join(' ')}
320
+ class:vertical
321
+ {id}
322
+ aria-hidden="true">
323
+ <div class="skeleton-inner">
324
+ {#if show_labels}
325
+ <span class="skeleton-label label-before"></span>
326
+ <span class="skeleton-label label-after"></span>
327
+ {/if}
328
+ <div class="skeleton-divider">
329
+ <div class="skeleton-handle"></div>
330
+ </div>
331
+ </div>
332
+ </div>
333
+ {:else}
334
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
335
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
336
+ <div
337
+ class={['comparison', class_name].filter(Boolean).join(' ')}
338
+ class:vertical
339
+ class:dragging
340
+ class:overflowing={needs_visible_overflow}
341
+ {id}
342
+ bind:this={container}
343
+ onclick={handleContainerClick}
344
+ onpointerdown={handlePointerDown}
345
+ onpointermove={handlePointerMove}
346
+ onpointerup={handlePointerUp}
347
+ onpointercancel={handlePointerUp}>
348
+ <img class="before" src={before} alt={before_alt} draggable="false" />
349
+
350
+ <img
351
+ class="after"
352
+ src={after}
353
+ alt={after_alt}
354
+ draggable="false"
355
+ style:clip-path={afterClipPath} />
356
+
357
+ {#if show_labels}
358
+ <span class="label label-before">{label_before}</span>
359
+ <span class="label label-after">{label_after}</span>
360
+ {/if}
361
+
362
+ <div
363
+ class="divider"
364
+ class:vertical
365
+ style={dividerStyle}
366
+ style:--divider-overshoot="{overshoot_px}px">
367
+ <div
368
+ class="handle"
369
+ role="slider"
370
+ tabindex="0"
371
+ aria-valuenow={Math.round(clampedPosition)}
372
+ aria-valuemin={0}
373
+ aria-valuemax={100}
374
+ aria-label="Comparison slider"
375
+ onkeydown={handleKeyDown}>
376
+ {#if vertical}
377
+ <svg viewBox="0 0 24 24" aria-hidden="true">
378
+ <path d="M12 4l-4 4h8zM12 20l-4-4h8z" fill="currentColor" />
379
+ </svg>
380
+ {:else}
381
+ <svg viewBox="0 0 24 24" aria-hidden="true">
382
+ <path d="M4 12l4-4v8zM20 12l-4-4v8z" fill="currentColor" />
383
+ </svg>
384
+ {/if}
385
+ </div>
386
+ </div>
387
+ </div>
388
+ {/if}
389
+
390
+ <style>
391
+ .comparison {
392
+ --handle-size: 40px;
393
+ --handle-color: var(--cmp-handle, #fff);
394
+ --handle-shadow: 0 0 6px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15);
395
+ --divider-color: var(--cmp-divider, #fff);
396
+ --divider-width: var(--width-divider, 2px);
397
+ --label-bg: var(--cmp-label-bg, rgba(0, 0, 0, 0.55));
398
+ --label-color: var(--cmp-label-text, #fff);
399
+ --label-padding: var(--padding-label, 4px 10px);
400
+ --label-radius: var(--radius-md, 4px);
401
+ --label-font-size: var(--text-sm, 0.8125rem);
402
+ /* Rounded media corners — override with --cmp-radius (0 to disable). Clamped
403
+ so an over-rounded radius can't blob this large surface — see --radius-cap. */
404
+ --_radius: min(var(--cmp-radius, var(--radius-xl, 20px)), var(--radius-cap, 40px));
405
+
406
+ position: relative;
407
+ overflow: hidden;
408
+ border-radius: var(--_radius);
409
+ @supports (corner-shape: squircle) {
410
+ corner-shape: squircle;
411
+ border-radius: calc(var(--_radius) * var(--squircle-ratio, 2));
412
+ }
413
+ touch-action: none;
414
+ user-select: none;
415
+ -webkit-user-select: none;
416
+ cursor: ew-resize;
417
+
418
+ &.vertical {
419
+ cursor: ns-resize;
420
+ }
421
+
422
+ &.dragging {
423
+ cursor: grabbing;
424
+ }
425
+
426
+ &.overflowing {
427
+ overflow: visible;
428
+ }
429
+
430
+ &.skeleton {
431
+ cursor: default;
432
+ touch-action: auto;
433
+ user-select: auto;
434
+ }
435
+ }
436
+
437
+ /* The real component's height comes from the image, which is unknowable
438
+ while loading — default to a 16/9 media well (override with
439
+ --cmp-aspect to match the real images and avoid any layout shift). */
440
+ .skeleton-inner {
441
+ width: 100%;
442
+ min-height: 200px;
443
+ aspect-ratio: var(--cmp-aspect, 16 / 9);
444
+ border-radius: var(--_radius);
445
+ @supports (corner-shape: squircle) {
446
+ corner-shape: squircle;
447
+ border-radius: calc(var(--_radius) * var(--squircle-ratio, 2));
448
+ }
449
+ background-color: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
450
+ position: relative;
451
+ overflow: hidden;
452
+ }
453
+
454
+ .skeleton-inner::after {
455
+ content: '';
456
+ position: absolute;
457
+ inset: 0;
458
+ z-index: 1;
459
+ transform: translateX(-100%);
460
+ background-image: linear-gradient(
461
+ 105deg,
462
+ transparent 25%,
463
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
464
+ transparent 75%
465
+ );
466
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
467
+ infinite;
468
+ }
469
+
470
+ @keyframes -global-delight-skeleton-shimmer {
471
+ 0% {
472
+ transform: translateX(-100%);
473
+ }
474
+ 55%,
475
+ 100% {
476
+ transform: translateX(100%);
477
+ }
478
+ }
479
+
480
+ /* Ghost divider + handle at the resting snap position. The handle
481
+ breathes gently so the placeholder feels alive even between sweeps. */
482
+ .skeleton-divider {
483
+ position: absolute;
484
+ top: 0;
485
+ bottom: 0;
486
+ left: 50%;
487
+ width: var(--divider-width);
488
+ transform: translateX(-50%);
489
+ background: var(--divider-color);
490
+ opacity: 0.55;
491
+
492
+ .vertical & {
493
+ top: 50%;
494
+ bottom: auto;
495
+ left: 0;
496
+ right: 0;
497
+ width: auto;
498
+ height: var(--divider-width);
499
+ transform: translateY(-50%);
500
+ }
501
+ }
502
+
503
+ .skeleton-handle {
504
+ position: absolute;
505
+ top: 50%;
506
+ left: 50%;
507
+ translate: -50% -50%;
508
+ width: var(--handle-size);
509
+ height: var(--handle-size);
510
+ border-radius: var(--radius-full, 9999px);
511
+ background: var(--handle-color);
512
+ box-shadow: var(--handle-shadow);
513
+ opacity: 0.75;
514
+ animation: comparison-handle-breathe 2.4s ease-in-out infinite;
515
+ }
516
+
517
+ @keyframes comparison-handle-breathe {
518
+ 50% {
519
+ scale: 1.08;
520
+ }
521
+ }
522
+
523
+ /* Ghost label pills in the real labels' corners */
524
+ .skeleton-label {
525
+ position: absolute;
526
+ z-index: 2;
527
+ width: 4.5em;
528
+ height: calc(1em + 8px);
529
+ font-size: var(--label-font-size);
530
+ border-radius: var(--label-radius);
531
+ background: var(--label-bg);
532
+ opacity: 0.45;
533
+ }
534
+
535
+ @media (prefers-reduced-motion: reduce) {
536
+ .skeleton-inner::after,
537
+ .skeleton-handle {
538
+ animation: none;
539
+ }
540
+ }
541
+
542
+ img {
543
+ display: block;
544
+ width: 100%;
545
+ height: 100%;
546
+ object-fit: cover;
547
+ /* The container clips to the rounded corners except while the divider
548
+ overshoots (overflow: visible) — round the images too so the corners
549
+ hold up during that drag. */
550
+ border-radius: var(--_radius);
551
+ @supports (corner-shape: squircle) {
552
+ corner-shape: squircle;
553
+ border-radius: calc(var(--_radius) * var(--squircle-ratio, 2));
554
+ }
555
+
556
+ &.before {
557
+ position: relative;
558
+ }
559
+
560
+ &.after {
561
+ position: absolute;
562
+ inset: 0;
563
+ transition: clip-path 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
564
+
565
+ .dragging & {
566
+ transition: none;
567
+ }
568
+ }
569
+ }
570
+
571
+ .label {
572
+ position: absolute;
573
+ padding: var(--label-padding);
574
+ background: var(--label-bg);
575
+ color: var(--label-color);
576
+ font-size: var(--label-font-size);
577
+ font-weight: 500;
578
+ border-radius: var(--label-radius);
579
+ @supports (corner-shape: squircle) {
580
+ corner-shape: squircle;
581
+ border-radius: calc(var(--label-radius) * var(--squircle-ratio, 2));
582
+ }
583
+ pointer-events: none;
584
+ z-index: 2;
585
+ line-height: 1;
586
+ }
587
+
588
+ .label-before {
589
+ top: 12px;
590
+ left: 12px;
591
+ }
592
+
593
+ .label-after {
594
+ bottom: 12px;
595
+ right: 12px;
596
+ }
597
+
598
+ .divider {
599
+ position: absolute;
600
+ z-index: 3;
601
+ display: flex;
602
+ align-items: center;
603
+ justify-content: center;
604
+ pointer-events: none;
605
+ transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
606
+
607
+ &::before {
608
+ content: '';
609
+ position: absolute;
610
+ background: var(--divider-color);
611
+ }
612
+
613
+ &:not(.vertical) {
614
+ width: 0;
615
+ transform: translateX(var(--divider-overshoot, 0px));
616
+ &::before {
617
+ width: var(--divider-width);
618
+ top: 0;
619
+ bottom: 0;
620
+ left: calc(var(--divider-width) / -2);
621
+ }
622
+ }
623
+
624
+ &.vertical {
625
+ height: 0;
626
+ transform: translateY(var(--divider-overshoot, 0px));
627
+ &::before {
628
+ height: var(--divider-width);
629
+ left: 0;
630
+ right: 0;
631
+ top: calc(var(--divider-width) / -2);
632
+ }
633
+ }
634
+
635
+ .dragging & {
636
+ transition: none;
637
+ }
638
+ }
639
+
640
+ .handle {
641
+ position: relative;
642
+ width: var(--handle-size);
643
+ height: var(--handle-size);
644
+ border-radius: 50%;
645
+ background: color-mix(in oklch, var(--handle-color) 55%, transparent);
646
+ box-shadow: var(--handle-shadow);
647
+ display: flex;
648
+ align-items: center;
649
+ justify-content: center;
650
+ pointer-events: auto;
651
+ cursor: grab;
652
+ flex-shrink: 0;
653
+ z-index: 1;
654
+ outline: none;
655
+ backdrop-filter: blur(10px) saturate(140%);
656
+ -webkit-backdrop-filter: blur(10px) saturate(140%);
657
+ border: 1px solid rgba(255, 255, 255, 0.5);
658
+ transition:
659
+ box-shadow 150ms ease,
660
+ background 150ms ease;
661
+
662
+ &:hover {
663
+ background: color-mix(in oklch, var(--handle-color) 70%, transparent);
664
+ /* Snap the tint in on hover; the base rule eases it back out on leave. */
665
+ transition: box-shadow 150ms ease;
666
+ }
667
+
668
+ &:focus-visible {
669
+ box-shadow:
670
+ var(--handle-shadow),
671
+ 0 0 0 3px rgba(59, 130, 246, 0.5);
672
+ }
673
+
674
+ .dragging & {
675
+ cursor: grabbing;
676
+ background: color-mix(in oklch, var(--handle-color) 80%, transparent);
677
+ }
678
+
679
+ svg {
680
+ width: 20px;
681
+ height: 20px;
682
+ color: rgba(0, 0, 0, 0.75);
683
+ pointer-events: none;
684
+ }
685
+ }
686
+ </style>