@reshape-biotech/design-system 0.0.54 → 1.1.2

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 (181) hide show
  1. package/dist/components/activity/Activity.stories.svelte +21 -8
  2. package/dist/components/activity/Activity.svelte +44 -6
  3. package/dist/components/activity/Activity.svelte.d.ts +1 -1
  4. package/dist/components/avatar/Avatar.stories.svelte +7 -17
  5. package/dist/components/avatar/Avatar.svelte +2 -2
  6. package/dist/components/avatar/Avatar.svelte.d.ts +2 -2
  7. package/dist/components/banner/Banner.stories.svelte +5 -5
  8. package/dist/components/banner/Banner.svelte +1 -1
  9. package/dist/components/button/Button.stories.svelte +54 -21
  10. package/dist/components/button/Button.svelte +58 -11
  11. package/dist/components/button/Button.svelte.d.ts +4 -3
  12. package/dist/components/card/Card.stories.svelte +130 -0
  13. package/dist/components/card/Card.stories.svelte.d.ts +19 -0
  14. package/dist/components/card/Card.svelte +25 -0
  15. package/dist/components/card/Card.svelte.d.ts +10 -0
  16. package/dist/components/card/index.d.ts +1 -0
  17. package/dist/components/card/index.js +1 -0
  18. package/dist/components/checkbox/Checkbox.stories.svelte +7 -15
  19. package/dist/components/checkbox/Checkbox.svelte +7 -7
  20. package/dist/components/checkbox/Checkbox.svelte.d.ts +3 -5
  21. package/dist/components/collapsible/Collapsible.stories.svelte +5 -4
  22. package/dist/components/combobox/Combobox.stories.svelte +7 -5
  23. package/dist/components/datepicker/DatePicker.stories.svelte +13 -18
  24. package/dist/components/datepicker/DatePicker.svelte +1 -0
  25. package/dist/components/divider/Divider.stories.svelte +1 -3
  26. package/dist/components/divider/Divider.svelte +8 -2
  27. package/dist/components/divider/Divider.svelte.d.ts +2 -0
  28. package/dist/components/drawer/Drawer.stories.svelte +3 -3
  29. package/dist/components/dropdown/Dropdown.stories.svelte +8 -8
  30. package/dist/components/empty-content/EmptyContent.stories.svelte +2 -2
  31. package/dist/components/graphs/bar-chart/BarChart.stories.svelte +81 -0
  32. package/dist/components/graphs/bar-chart/BarChart.stories.svelte.d.ts +19 -0
  33. package/dist/components/graphs/bar-chart/BarChart.svelte +136 -0
  34. package/dist/components/graphs/bar-chart/BarChart.svelte.d.ts +15 -0
  35. package/dist/components/graphs/bar-chart/StackedBarChart.stories.svelte +42 -0
  36. package/dist/components/graphs/bar-chart/StackedBarChart.stories.svelte.d.ts +19 -0
  37. package/dist/components/graphs/bar-chart/StackedBarChart.svelte +177 -0
  38. package/dist/components/graphs/bar-chart/StackedBarChart.svelte.d.ts +16 -0
  39. package/dist/components/graphs/chart/Chart.stories.svelte +30 -25
  40. package/dist/components/graphs/chart/Chart.svelte +104 -32
  41. package/dist/components/graphs/chart/Chart.svelte.d.ts +15 -5
  42. package/dist/components/graphs/index.d.ts +3 -0
  43. package/dist/components/graphs/index.js +3 -0
  44. package/dist/components/graphs/line/LineChart.stories.svelte +97 -17
  45. package/dist/components/graphs/line/LineChart.svelte +90 -51
  46. package/dist/components/graphs/line/LineChart.svelte.d.ts +6 -13
  47. package/dist/components/graphs/matrix/Matrix.stories.svelte +142 -0
  48. package/dist/components/graphs/matrix/Matrix.stories.svelte.d.ts +19 -0
  49. package/dist/components/graphs/matrix/Matrix.svelte +149 -0
  50. package/dist/components/graphs/matrix/Matrix.svelte.d.ts +24 -0
  51. package/dist/components/graphs/matrix/index.d.ts +3 -0
  52. package/dist/components/graphs/matrix/index.js +3 -0
  53. package/dist/components/graphs/multiline/MultiLineChart.stories.svelte +115 -28
  54. package/dist/components/graphs/multiline/MultiLineChart.svelte +187 -50
  55. package/dist/components/graphs/multiline/MultiLineChart.svelte.d.ts +9 -12
  56. package/dist/components/graphs/scatterplot/Scatterplot.stories.svelte +68 -41
  57. package/dist/components/graphs/scatterplot/Scatterplot.svelte +312 -45
  58. package/dist/components/graphs/scatterplot/Scatterplot.svelte.d.ts +23 -13
  59. package/dist/components/graphs/utils/tooltipFormatter.d.ts +10 -0
  60. package/dist/components/graphs/utils/tooltipFormatter.js +52 -0
  61. package/dist/components/icon-button/IconButton.stories.svelte +6 -6
  62. package/dist/components/icon-button/IconButton.svelte +50 -9
  63. package/dist/components/icon-button/IconButton.svelte.d.ts +3 -5
  64. package/dist/components/icons/AnalysisIcon.stories.svelte +15 -21
  65. package/dist/components/icons/AnalysisIcon.svelte +63 -44
  66. package/dist/components/icons/AnalysisIcon.svelte.d.ts +1 -0
  67. package/dist/components/icons/Icon.stories.svelte +4 -4
  68. package/dist/components/icons/Icon.svelte +1 -1
  69. package/dist/components/icons/PrincipalIcon.svelte +96 -0
  70. package/dist/components/icons/PrincipalIcon.svelte.d.ts +10 -0
  71. package/dist/components/icons/custom/Halo.svelte +14 -8
  72. package/dist/components/icons/custom/Halo.svelte.d.ts +7 -25
  73. package/dist/components/icons/custom/Well.svelte +14 -6
  74. package/dist/components/icons/custom/Well.svelte.d.ts +7 -25
  75. package/dist/components/icons/index.d.ts +3 -2
  76. package/dist/components/icons/index.js +34 -0
  77. package/dist/components/input/Input.stories.svelte +16 -22
  78. package/dist/components/input/Input.svelte +140 -134
  79. package/dist/components/input/Input.svelte.d.ts +12 -13
  80. package/dist/components/label/Label.stories.svelte +28 -0
  81. package/dist/components/label/Label.stories.svelte.d.ts +19 -0
  82. package/dist/components/label/Label.svelte +17 -0
  83. package/dist/components/label/Label.svelte.d.ts +9 -0
  84. package/dist/components/list/List.stories.svelte +3 -3
  85. package/dist/components/logo/Logo.stories.svelte +1 -1
  86. package/dist/components/manual-cfu-counter/ManualCFUCounter.stories.svelte +125 -0
  87. package/dist/components/manual-cfu-counter/ManualCFUCounter.stories.svelte.d.ts +3 -0
  88. package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte +577 -0
  89. package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte.d.ts +16 -0
  90. package/dist/components/manual-cfu-counter/index.d.ts +1 -0
  91. package/dist/components/manual-cfu-counter/index.js +1 -0
  92. package/dist/components/manual-cfu-counter/test/ManualCFUCounterTestWrapper.svelte +22 -0
  93. package/dist/components/manual-cfu-counter/test/ManualCFUCounterTestWrapper.svelte.d.ts +19 -0
  94. package/dist/components/markdown/Markdown.stories.svelte +1 -1
  95. package/dist/components/markdown/Markdown.svelte +1 -1
  96. package/dist/components/modal/Modal.stories.svelte +2 -2
  97. package/dist/components/modal/Modal.svelte +27 -22
  98. package/dist/components/modal/Modal.svelte.d.ts +4 -1
  99. package/dist/components/notification-popup/NotificationPopup.stories.svelte +1 -1
  100. package/dist/components/pill/Pill.stories.svelte +13 -0
  101. package/dist/components/pill/Pill.stories.svelte.d.ts +19 -0
  102. package/dist/components/progress-circle/ProgressCircle.stories.svelte +15 -0
  103. package/dist/components/progress-circle/ProgressCircle.stories.svelte.d.ts +19 -0
  104. package/dist/components/required-status-indicator/RequiredStatusIndicator.stories.svelte +28 -0
  105. package/dist/components/required-status-indicator/RequiredStatusIndicator.stories.svelte.d.ts +19 -0
  106. package/dist/components/required-status-indicator/RequiredStatusIndicator.svelte +22 -0
  107. package/dist/components/required-status-indicator/RequiredStatusIndicator.svelte.d.ts +8 -0
  108. package/dist/components/required-status-indicator/index.d.ts +1 -0
  109. package/dist/components/required-status-indicator/index.js +1 -0
  110. package/dist/components/segmented-control-buttons/SegmentedControlButtons.stories.svelte +3 -3
  111. package/dist/components/select/Select.stories.svelte +12 -12
  112. package/dist/components/select/Select.svelte +0 -2
  113. package/dist/components/select-new/Select.stories.svelte +219 -0
  114. package/dist/components/select-new/Select.stories.svelte.d.ts +19 -0
  115. package/dist/components/select-new/components/Group.svelte +24 -0
  116. package/dist/components/select-new/components/Group.svelte.d.ts +11 -0
  117. package/dist/components/select-new/components/MultiSelectTrigger.svelte +66 -0
  118. package/dist/components/select-new/components/MultiSelectTrigger.svelte.d.ts +17 -0
  119. package/dist/components/select-new/components/SelectContent.svelte +33 -0
  120. package/dist/components/select-new/components/SelectContent.svelte.d.ts +10 -0
  121. package/dist/components/select-new/components/SelectGroupHeading.svelte +19 -0
  122. package/dist/components/select-new/components/SelectGroupHeading.svelte.d.ts +9 -0
  123. package/dist/components/select-new/components/SelectItem.svelte +41 -0
  124. package/dist/components/select-new/components/SelectItem.svelte.d.ts +9 -0
  125. package/dist/components/select-new/components/SelectTrigger.svelte +48 -0
  126. package/dist/components/select-new/components/SelectTrigger.svelte.d.ts +12 -0
  127. package/dist/components/select-new/index.d.ts +10 -0
  128. package/dist/components/select-new/index.js +12 -0
  129. package/dist/components/select-new/types.d.ts +25 -0
  130. package/dist/components/select-new/types.js +1 -0
  131. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte +92 -0
  132. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte.d.ts +3 -0
  133. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte +65 -0
  134. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte.d.ts +3 -0
  135. package/dist/components/sjsf-wrappers/index.d.ts +2 -0
  136. package/dist/components/sjsf-wrappers/index.js +2 -0
  137. package/dist/components/sjsf-wrappers/sjsfCustomTheme.d.ts +2 -0
  138. package/dist/components/sjsf-wrappers/sjsfCustomTheme.js +8 -0
  139. package/dist/components/skeleton-loader/SkeletonLoader.stories.svelte +4 -4
  140. package/dist/components/slider/Slider.stories.svelte +4 -4
  141. package/dist/components/spinner/Spinner.stories.svelte +13 -0
  142. package/dist/components/spinner/Spinner.stories.svelte.d.ts +19 -0
  143. package/dist/components/stat-card/StatCard.stories.svelte +27 -19
  144. package/dist/components/stat-card/StatCard.svelte +100 -6
  145. package/dist/components/stat-card/StatCard.svelte.d.ts +3 -0
  146. package/dist/components/status-badge/StatusBadge.stories.svelte +6 -6
  147. package/dist/components/status-badge/StatusBadge.svelte +5 -3
  148. package/dist/components/stepper/Stepper.stories.svelte +243 -0
  149. package/dist/components/stepper/Stepper.stories.svelte.d.ts +19 -0
  150. package/dist/components/stepper/components/stepper-root.svelte +20 -0
  151. package/dist/components/stepper/components/stepper-root.svelte.d.ts +9 -0
  152. package/dist/components/stepper/components/stepper-step.svelte +100 -0
  153. package/dist/components/stepper/components/stepper-step.svelte.d.ts +11 -0
  154. package/dist/components/stepper/index.d.ts +15 -0
  155. package/dist/components/stepper/index.js +2 -0
  156. package/dist/components/table/Table.stories.svelte +1 -1
  157. package/dist/components/table/components/Td.svelte +3 -2
  158. package/dist/components/table/components/Td.svelte.d.ts +1 -0
  159. package/dist/components/table/components/Tr.svelte +3 -2
  160. package/dist/components/table/components/Tr.svelte.d.ts +1 -0
  161. package/dist/components/tabs/Tabs.stories.svelte +1 -1
  162. package/dist/components/tag/Tag.stories.svelte +9 -9
  163. package/dist/components/tag/Tag.svelte +0 -18
  164. package/dist/components/textarea/Textarea.stories.svelte +97 -0
  165. package/dist/components/textarea/Textarea.stories.svelte.d.ts +19 -0
  166. package/dist/components/textarea/Textarea.svelte +94 -0
  167. package/dist/components/textarea/Textarea.svelte.d.ts +17 -0
  168. package/dist/components/textarea/index.d.ts +1 -0
  169. package/dist/components/textarea/index.js +1 -0
  170. package/dist/components/toggle/Toggle.stories.svelte +1 -1
  171. package/dist/components/toggle/Toggle.svelte +3 -2
  172. package/dist/components/toggle/Toggle.svelte.d.ts +1 -0
  173. package/dist/components/toggle-icon-button/ToggleIconButton.stories.svelte +6 -6
  174. package/dist/components/tooltip/Tooltip.stories.svelte +6 -6
  175. package/dist/components/tooltip/Tooltip.svelte +1 -1
  176. package/dist/index.d.ts +7 -0
  177. package/dist/index.js +7 -0
  178. package/dist/tailwind.preset.d.ts +5 -0
  179. package/dist/tokens.d.ts +10 -0
  180. package/dist/tokens.js +6 -4
  181. package/package.json +1 -2
@@ -0,0 +1,577 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { Button } from '../button';
4
+ import { textColor } from '../../tokens';
5
+ import { Icon } from '../icons';
6
+ import IconButton from '../icon-button/IconButton.svelte';
7
+ import Divider from '../divider/Divider.svelte';
8
+
9
+ // Constants for the component
10
+ const BASE_IMAGE_SIZE = 464; // Base size, will be overriden by container dimensions
11
+ const BASE_MARKER_SIZE = 12; // Base size at zoom level 1
12
+ const BASE_MARKER_FONT_SIZE = 10; // Base font size at zoom level 1
13
+ const MAX_ZOOM = 10;
14
+ const MIN_ZOOM = 1;
15
+ const ZOOM_STEP = 0.001;
16
+ const MARKER_COLOR = textColor['icon-blue'] || '#2563eb';
17
+ const TEXT_COLOR = textColor['primary-inverse'];
18
+ const DRAG_THRESHOLD = 5;
19
+
20
+ interface Props {
21
+ imageUrl: string;
22
+ marks?: Array<{ x: number; y: number }>;
23
+ onclick?: (event: MouseEvent, marks: Array<{ x: number; y: number }>) => void;
24
+ disabled?: boolean;
25
+ hideMarkers?: boolean;
26
+ }
27
+
28
+ let {
29
+ imageUrl,
30
+ onclick,
31
+ disabled = false,
32
+ hideMarkers = false,
33
+ marks = $bindable([])
34
+ }: Props = $props();
35
+
36
+ let svgElement: SVGSVGElement;
37
+ let viewport: SVGGraphicsElement;
38
+ let dotsGroup: SVGElement;
39
+ let marksHistory: Array<Array<{ x: number; y: number }>> = $state([]);
40
+ let container: HTMLDivElement;
41
+
42
+ let containerW = BASE_IMAGE_SIZE;
43
+ let containerH = BASE_IMAGE_SIZE;
44
+
45
+ let transform = $state({ x: 0, y: 0, scale: 1 });
46
+
47
+ // Panning state: null = not panning, 'ready' = mouse button down, 'active' = dragging
48
+ let panningState = $state<null | 'ready' | 'active'>(null);
49
+ let startPoint = $state({ x: 0, y: 0 });
50
+ let isShiftPressed = $state(false);
51
+ let resizeObserver: ResizeObserver;
52
+
53
+ let imageAspectRatio = $state(1); // default 1:1 ratio
54
+ let imageDisplayWidth = $state(0);
55
+ let imageDisplayHeight = $state(0);
56
+
57
+ function handleResize(entries: ResizeObserverEntry[]) {
58
+ const entry = entries[0];
59
+ if (entry) {
60
+ const oldWidth = imageDisplayWidth || containerW;
61
+ const oldHeight = imageDisplayHeight || containerH;
62
+
63
+ containerW = entry.contentRect.width;
64
+ containerH = entry.contentRect.height;
65
+
66
+ // If we already had dimensions and marks, update their positions
67
+ if (oldWidth > 0 && oldHeight > 0 && marks.length > 0) {
68
+ // Adjust marks positions based on new container dimensions
69
+ marks = marks.map((mark) => ({
70
+ x: (mark.x / oldWidth) * containerW,
71
+ y: (mark.y / oldHeight) * containerH
72
+ }));
73
+ }
74
+
75
+ updateImageDimensions();
76
+ }
77
+ }
78
+
79
+ function onImageLoad() {
80
+ if (container) {
81
+ containerW = container.clientWidth;
82
+ containerH = container.clientHeight;
83
+
84
+ // get original image aspect ratio
85
+ const img = new Image();
86
+ img.src = imageUrl;
87
+ img.onload = () => {
88
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
89
+ updateImageDimensions();
90
+ };
91
+
92
+ if (img.complete && img.naturalWidth) {
93
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
94
+ updateImageDimensions();
95
+ }
96
+ }
97
+ }
98
+
99
+ function updateImageDimensions() {
100
+ // calculate dimensions that maintain aspect ratio within container
101
+ const containerRatio = containerW / containerH;
102
+
103
+ if (containerRatio > imageAspectRatio) {
104
+ // container is wider than image - fit to height
105
+ imageDisplayHeight = containerH;
106
+ imageDisplayWidth = containerH * imageAspectRatio;
107
+ } else {
108
+ // container is taller than image - fit to width
109
+ imageDisplayWidth = containerW;
110
+ imageDisplayHeight = containerW / imageAspectRatio;
111
+ }
112
+
113
+ updateTransform();
114
+ }
115
+
116
+ // Properly constrain the transform to prevent moving outside container
117
+ function clampTransform() {
118
+ // Calculate maximum translation offsets based on scale > 1
119
+ const maxX = 0;
120
+ const maxY = 0;
121
+ // Prevent division by zero or unexpected behavior if container dimensions aren't ready
122
+ const safeContainerW = containerW || 1;
123
+ const safeContainerH = containerH || 1;
124
+ const minX = safeContainerW * (1 - transform.scale);
125
+ const minY = safeContainerH * (1 - transform.scale);
126
+
127
+ if (transform.scale <= 1) {
128
+ // If scale is 1 or less, center the view
129
+ // Check if scale is exactly 1 before modifying to avoid floating point issues if needed
130
+ // Or simply reset if it goes below
131
+ transform.scale = 1;
132
+ transform.x = 0;
133
+ transform.y = 0;
134
+ } else {
135
+ // Apply clamping for scale > 1
136
+ transform.x = Math.max(minX, Math.min(maxX, transform.x));
137
+ transform.y = Math.max(minY, Math.min(maxY, transform.y));
138
+ }
139
+ }
140
+
141
+ function updateTransform() {
142
+ if (!viewport) return;
143
+
144
+ // update viewport transform
145
+ viewport.setAttribute(
146
+ 'transform',
147
+ `translate(${transform.x}, ${transform.y}) scale(${transform.scale})`
148
+ );
149
+
150
+ // set image dimensions based on calculated size that maintains aspect ratio
151
+ const imageElement = viewport.querySelector('image');
152
+ if (imageElement) {
153
+ imageElement.setAttribute('width', String(imageDisplayWidth));
154
+ imageElement.setAttribute('height', String(imageDisplayHeight));
155
+
156
+ // center the image in the container
157
+ const offsetX = (containerW - imageDisplayWidth) / 2;
158
+ const offsetY = (containerH - imageDisplayHeight) / 2;
159
+
160
+ imageElement.setAttribute('x', String(offsetX));
161
+ imageElement.setAttribute('y', String(offsetY));
162
+ }
163
+
164
+ // update markers to maintain consistent size
165
+ renderMarkers();
166
+ }
167
+
168
+ function getSvgPoint(event: MouseEvent) {
169
+ if (!svgElement || !container) return { x: 0, y: 0 };
170
+
171
+ // get container bounding rect
172
+ const rect = container.getBoundingClientRect();
173
+
174
+ // calculate position relative to container
175
+ const relativeX = event.clientX - rect.left;
176
+ const relativeY = event.clientY - rect.top;
177
+
178
+ // apply inverse of current transform to get SVG coordinates
179
+ const svgX = (relativeX - transform.x) / transform.scale;
180
+ const svgY = (relativeY - transform.y) / transform.scale;
181
+
182
+ // adjust for image position within container (letterboxing)
183
+ const offsetX = (containerW - imageDisplayWidth) / 2;
184
+ const offsetY = (containerH - imageDisplayHeight) / 2;
185
+
186
+ // return coordinates relative to the image, not the container
187
+ return {
188
+ x: svgX - offsetX,
189
+ y: svgY - offsetY
190
+ };
191
+ }
192
+
193
+ // Render SVG markers from the marks array
194
+ function renderMarkers() {
195
+ if (!dotsGroup) return;
196
+
197
+ while (dotsGroup.firstChild) {
198
+ dotsGroup.removeChild(dotsGroup.firstChild);
199
+ }
200
+
201
+ const offsetX = (containerW - imageDisplayWidth) / 2;
202
+ const offsetY = (containerH - imageDisplayHeight) / 2;
203
+
204
+ const svgns = 'http://www.w3.org/2000/svg';
205
+ const adjustedMarkerSize = BASE_MARKER_SIZE / transform.scale;
206
+ const adjustedFontSize = BASE_MARKER_FONT_SIZE / transform.scale;
207
+
208
+ marks.forEach((mark, index) => {
209
+ // Create a marker group
210
+ const group = document.createElementNS(svgns, 'g');
211
+ group.setAttribute('data-testid', `marker-${index + 1}`);
212
+
213
+ const markX = mark.x + offsetX;
214
+ const markY = mark.y + offsetY;
215
+
216
+ const circle = document.createElementNS(svgns, 'circle');
217
+ circle.setAttribute('cx', String(markX));
218
+ circle.setAttribute('cy', String(markY));
219
+ circle.setAttribute('r', String(adjustedMarkerSize));
220
+ circle.setAttribute('fill', MARKER_COLOR);
221
+ circle.setAttribute('class', 'drop-shadow-sm');
222
+ group.appendChild(circle);
223
+
224
+ const text = document.createElementNS(svgns, 'text');
225
+ text.setAttribute('x', String(markX));
226
+ text.setAttribute('y', String(markY));
227
+ text.setAttribute('text-anchor', 'middle');
228
+ text.setAttribute('dominant-baseline', 'central');
229
+ text.setAttribute('fill', TEXT_COLOR);
230
+ text.setAttribute('font-size', String(adjustedFontSize));
231
+ text.setAttribute('font-weight', 'bold');
232
+ text.textContent = String(index + 1);
233
+ group.appendChild(text);
234
+
235
+ dotsGroup.appendChild(group);
236
+ });
237
+ }
238
+
239
+ function handleClick(event: MouseEvent) {
240
+ if (disabled || hideMarkers || !dotsGroup || isShiftPressed || panningState !== null) {
241
+ panningState = null;
242
+ return;
243
+ }
244
+ saveCurrentState();
245
+ const pt = getSvgPoint(event);
246
+ marks.push({ x: pt.x, y: pt.y });
247
+
248
+ renderMarkers();
249
+ if (onclick) {
250
+ onclick(event, marks);
251
+ }
252
+ }
253
+
254
+ function handleKeyDown(event: KeyboardEvent) {
255
+ if (event.shiftKey) {
256
+ isShiftPressed = true;
257
+ }
258
+ }
259
+
260
+ function handleKeyUp(event: KeyboardEvent) {
261
+ let shouldResetReadyState = false;
262
+
263
+ if (!event.shiftKey && isShiftPressed) {
264
+ isShiftPressed = false;
265
+ shouldResetReadyState = true;
266
+ }
267
+
268
+ // If panning was in 'ready' state and the relevant key was released, reset state
269
+ if (shouldResetReadyState && panningState === 'ready') {
270
+ panningState = null;
271
+ }
272
+ }
273
+
274
+ function handleMouseDown(event: MouseEvent) {
275
+ // Only start panning on right click, shift+left click
276
+ const isPanningTrigger = event.button === 2 || (event.button === 0 && isShiftPressed);
277
+ if (!isPanningTrigger) return;
278
+
279
+ event.preventDefault(); // Prevent default context menu or text selection
280
+ panningState = 'ready';
281
+ startPoint = { x: event.clientX, y: event.clientY };
282
+ }
283
+
284
+ // Handle panning on mouse move
285
+ function handleMouseMove(event: MouseEvent) {
286
+ if (panningState === null) return;
287
+
288
+ const dx = event.clientX - startPoint.x;
289
+ const dy = event.clientY - startPoint.y;
290
+
291
+ // Detect if we're dragging (moved beyond threshold)
292
+ if (panningState === 'ready' && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
293
+ panningState = 'active';
294
+ }
295
+
296
+ // Only update position if we're actually panning
297
+ if (panningState === 'active') {
298
+ startPoint = { x: event.clientX, y: event.clientY };
299
+
300
+ transform.x += dx;
301
+ transform.y += dy;
302
+
303
+ // Ensure pan stays within bounds
304
+ clampTransform();
305
+ updateTransform();
306
+ }
307
+ }
308
+
309
+ function handleMouseUp() {
310
+ panningState = null;
311
+ }
312
+
313
+ function handleMouseLeave() {
314
+ panningState = null;
315
+ }
316
+
317
+ function handleContextMenu(event: MouseEvent) {
318
+ event.preventDefault();
319
+ }
320
+
321
+ function handleWheel(event: WheelEvent) {
322
+ event.preventDefault();
323
+
324
+ if (!svgElement || !viewport || !container) return;
325
+
326
+ // Update container dimensions just in case they changed
327
+ containerW = container.clientWidth;
328
+ containerH = container.clientHeight;
329
+
330
+ const zoomIntensity = ZOOM_STEP;
331
+ const delta = event.deltaY;
332
+ const zoomFactor = 1 - delta * zoomIntensity;
333
+ const currentScale = transform.scale; // Store current scale before modification
334
+
335
+ let potentialNewScale = currentScale * zoomFactor;
336
+
337
+ // Clamp the scale
338
+ let finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
339
+
340
+ if (finalScale <= MIN_ZOOM) {
341
+ // Reset view if zooming out below minimum or exactly at minimum
342
+ transform.scale = MIN_ZOOM;
343
+ transform.x = 0;
344
+ transform.y = 0;
345
+ } else {
346
+ // Calculate transform based on finalScale (which might be MAX_ZOOM)
347
+ const rect = container.getBoundingClientRect();
348
+ const mouseX = event.clientX - rect.left;
349
+ const mouseY = event.clientY - rect.top;
350
+
351
+ // Calculate distance from origin at current scale
352
+ const distX = mouseX - transform.x;
353
+ const distY = mouseY - transform.y;
354
+
355
+ // Calculate what the distance should be at the final scale
356
+ // Use currentScale from *before* this zoom event for the ratio
357
+ const scaleRatio = finalScale / currentScale;
358
+ const newDistX = distX * scaleRatio;
359
+ const newDistY = distY * scaleRatio;
360
+
361
+ // Update the transform to maintain the mouse position relative to the final scale
362
+ transform.x = mouseX - newDistX;
363
+ transform.y = mouseY - newDistY;
364
+ transform.scale = finalScale;
365
+ }
366
+
367
+ clampTransform();
368
+ updateTransform();
369
+ }
370
+
371
+ function saveCurrentState() {
372
+ marksHistory.push([...marks]);
373
+ }
374
+
375
+ function undo() {
376
+ if (marksHistory.length === 0) return;
377
+ const previousState = marksHistory.pop();
378
+ if (previousState) {
379
+ marks.splice(0, marks.length, ...previousState);
380
+ marks = marks;
381
+ renderMarkers();
382
+ }
383
+ }
384
+
385
+ function reset() {
386
+ if (marks.length === 0) return;
387
+ saveCurrentState();
388
+
389
+ marks = [];
390
+ renderMarkers();
391
+ }
392
+
393
+ // --- Zoom Functions ---
394
+ function applyZoom(zoomFactor: number) {
395
+ if (!container) return;
396
+
397
+ const currentScale = transform.scale;
398
+ const potentialNewScale = currentScale * zoomFactor;
399
+ const finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
400
+
401
+ // If scale didn't change (already at min/max), do nothing
402
+ if (finalScale === currentScale) return;
403
+
404
+ // Get center of the container view
405
+ const centerX = containerW / 2;
406
+ const centerY = containerH / 2;
407
+
408
+ // Calculate distance from origin at current scale
409
+ const distX = centerX - transform.x;
410
+ const distY = centerY - transform.y;
411
+
412
+ // Calculate what the distance should be at the final scale
413
+ const scaleRatio = finalScale / currentScale;
414
+ const newDistX = distX * scaleRatio;
415
+ const newDistY = distY * scaleRatio;
416
+
417
+ // Update the transform to maintain the center position relative to the final scale
418
+ transform.x = centerX - newDistX;
419
+ transform.y = centerY - newDistY;
420
+ transform.scale = finalScale;
421
+
422
+ clampTransform();
423
+ updateTransform();
424
+ }
425
+
426
+ function zoomIn() {
427
+ applyZoom(1.2); // Zoom in by 20%
428
+ }
429
+
430
+ function zoomOut() {
431
+ applyZoom(1 / 1.2); // Zoom out by 20%
432
+ }
433
+
434
+ onMount(() => {
435
+ if (container) {
436
+ containerW = container.clientWidth;
437
+ containerH = container.clientHeight;
438
+
439
+ // setup resize observer
440
+ resizeObserver = new ResizeObserver(handleResize);
441
+ resizeObserver.observe(container);
442
+ }
443
+
444
+ window.addEventListener('keydown', handleKeyDown);
445
+ window.addEventListener('keyup', handleKeyUp);
446
+
447
+ updateTransform();
448
+ renderMarkers();
449
+
450
+ const img = new Image();
451
+ img.onload = onImageLoad;
452
+ img.src = imageUrl;
453
+
454
+ if (img.complete) {
455
+ onImageLoad();
456
+ }
457
+
458
+ return () => {
459
+ window.removeEventListener('keydown', handleKeyDown);
460
+ window.removeEventListener('keyup', handleKeyUp);
461
+ // cleanup resize observer
462
+ if (resizeObserver) {
463
+ resizeObserver.disconnect();
464
+ }
465
+ };
466
+ });
467
+
468
+ // Effect to render when marks prop changes from parent or internally
469
+ $effect(() => {
470
+ renderMarkers();
471
+ });
472
+ </script>
473
+
474
+ {#snippet TopLeftActions()}
475
+ {#if marks.length > 0}
476
+ <div
477
+ class="absolute left-2 top-2 z-20 flex items-center gap-1 rounded-lg border border-interactive-inverse bg-base-inverse p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
478
+ >
479
+ <IconButton
480
+ variant="transparent-inverse"
481
+ rounded={false}
482
+ onclick={undo}
483
+ disabled={marksHistory.length === 0 || disabled}
484
+ aria-label="Undo last mark"
485
+ >
486
+ <Icon iconName={'ArrowUUpLeft'} />
487
+ </IconButton>
488
+ <Divider vertical inverse class="!h-5" />
489
+ <Button
490
+ variant="transparent-inverse"
491
+ size="sm"
492
+ onClick={reset}
493
+ disabled={marks.length === 0 || disabled}
494
+ accessibilityLabel="Reset all marks"
495
+ class="!text-primary-inverse"
496
+ >
497
+ Clear all
498
+ </Button>
499
+ </div>
500
+ {/if}
501
+ {/snippet}
502
+
503
+ {#snippet ZoomControls()}
504
+ <div
505
+ class="absolute bottom-2 right-2 z-20 flex flex-col items-center gap-1 rounded-md border border-interactive-inverse bg-base-inverse p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
506
+ >
507
+ <IconButton
508
+ variant="transparent-inverse"
509
+ rounded={false}
510
+ onclick={zoomIn}
511
+ aria-label="Zoom In"
512
+ disabled={transform.scale >= MAX_ZOOM || disabled}
513
+ >
514
+ <Icon iconName={'Plus'} />
515
+ </IconButton>
516
+ <IconButton
517
+ variant="transparent-inverse"
518
+ rounded={false}
519
+ onclick={zoomOut}
520
+ aria-label="Zoom Out"
521
+ disabled={transform.scale <= MIN_ZOOM || disabled}
522
+ >
523
+ <Icon iconName={'Minus'} />
524
+ </IconButton>
525
+ </div>
526
+ {/snippet}
527
+
528
+ <div bind:this={container} class="group relative h-full w-full overflow-hidden rounded-lg border">
529
+ {#if !hideMarkers && !disabled}
530
+ {@render TopLeftActions()}
531
+ {@render ZoomControls()}
532
+ {/if}
533
+
534
+ <!--
535
+ We need to use SVG for this interactive component.
536
+ The SVG element is treated as a canvas for clicking, panning, and zooming.
537
+ We add accessibility attributes to make it more accessible despite the interactive nature.
538
+ -->
539
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
540
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
541
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
542
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
543
+ <svg
544
+ bind:this={svgElement}
545
+ onclick={handleClick}
546
+ onmousedown={handleMouseDown}
547
+ onmousemove={handleMouseMove}
548
+ onmouseup={handleMouseUp}
549
+ onmouseleave={handleMouseLeave}
550
+ oncontextmenu={handleContextMenu}
551
+ onwheel={handleWheel}
552
+ class:cursor-grabbing={panningState === 'active'}
553
+ class:cursor-grab={!disabled && (panningState === 'ready' || (isShiftPressed && !panningState))}
554
+ class:cursor-not-allowed={disabled}
555
+ class:cursor-crosshair={!disabled && !panningState && !isShiftPressed}
556
+ class="h-full w-full"
557
+ role="application"
558
+ aria-label={disabled
559
+ ? 'CFU Counter (disabled)'
560
+ : 'CFU Counter - Click to add markers, right click or shift+click to pan'}
561
+ tabindex="0"
562
+ >
563
+ <g bind:this={viewport} id="viewport" class="h-full w-full">
564
+ <image href={imageUrl} x="0" y="0" width="100%" />
565
+ <g
566
+ bind:this={dotsGroup}
567
+ id="dots"
568
+ class="pointer-events-none"
569
+ aria-hidden={hideMarkers}
570
+ class:hidden={hideMarkers}
571
+ />
572
+ </g>
573
+ </svg>
574
+
575
+ <!-- Debug info for marks count - useful for testing -->
576
+ <span class="sr-only" data-testid="marks-count">{marks.length}</span>
577
+ </div>
@@ -0,0 +1,16 @@
1
+ interface Props {
2
+ imageUrl: string;
3
+ marks?: Array<{
4
+ x: number;
5
+ y: number;
6
+ }>;
7
+ onclick?: (event: MouseEvent, marks: Array<{
8
+ x: number;
9
+ y: number;
10
+ }>) => void;
11
+ disabled?: boolean;
12
+ hideMarkers?: boolean;
13
+ }
14
+ declare const ManualCfuCounter: import("svelte").Component<Props, {}, "marks">;
15
+ type ManualCfuCounter = ReturnType<typeof ManualCfuCounter>;
16
+ export default ManualCfuCounter;
@@ -0,0 +1 @@
1
+ export { default as ManualCFUCounter } from './ManualCFUCounter.svelte';
@@ -0,0 +1 @@
1
+ export { default as ManualCFUCounter } from './ManualCFUCounter.svelte';
@@ -0,0 +1,22 @@
1
+ <!-- src/lib/design-system/src/lib/components/manual-cfu-counter/ManualCFUCounterTestWrapper.svelte -->
2
+ <script lang="ts">
3
+ import ManualCFUCounter from '../ManualCFUCounter.svelte';
4
+
5
+ interface Props {
6
+ initialTestMarks?: Array<{ x: number; y: number }>;
7
+ imageUrl?: string;
8
+ onclick?: (event: MouseEvent, marks: Array<{ x: number; y: number }>) => void;
9
+ }
10
+
11
+ let { initialTestMarks = [], imageUrl = 'test-image.jpg', onclick }: Props = $props();
12
+
13
+ let marks = $state([...initialTestMarks]);
14
+
15
+ export function getCurrentMarks() {
16
+ return marks;
17
+ }
18
+ </script>
19
+
20
+ <ManualCFUCounter bind:marks {imageUrl} {onclick} />
21
+
22
+ <span data-testid="wrapper-marks-count" class="sr-only">{marks.length}</span>
@@ -0,0 +1,19 @@
1
+ interface Props {
2
+ initialTestMarks?: Array<{
3
+ x: number;
4
+ y: number;
5
+ }>;
6
+ imageUrl?: string;
7
+ onclick?: (event: MouseEvent, marks: Array<{
8
+ x: number;
9
+ y: number;
10
+ }>) => void;
11
+ }
12
+ declare const ManualCfuCounterTestWrapper: import("svelte").Component<Props, {
13
+ getCurrentMarks: () => {
14
+ x: number;
15
+ y: number;
16
+ }[];
17
+ }, "">;
18
+ type ManualCfuCounterTestWrapper = ReturnType<typeof ManualCfuCounterTestWrapper>;
19
+ export default ManualCfuCounterTestWrapper;
@@ -10,7 +10,7 @@
10
10
  });
11
11
  </script>
12
12
 
13
- <Story name="Primary">
13
+ <Story name="Primary" asChild>
14
14
  <Markdown
15
15
  markdown={`# Welcome to My Markdown Story
16
16
 
@@ -7,6 +7,6 @@
7
7
  let { markdown }: Props = $props();
8
8
  </script>
9
9
 
10
- <div class="prose text-sm text-tertiary dark:prose-invert">
10
+ <div class="prose text-sm text-secondary dark:prose-invert">
11
11
  {@html marked.parse(markdown ?? '')}
12
12
  </div>
@@ -12,7 +12,7 @@
12
12
  let defaultOpen = $state(true);
13
13
  </script>
14
14
 
15
- <Story name="Default">
15
+ <Story name="Default" asChild>
16
16
  <Modal>
17
17
  {#snippet Trigger({ openModal })}
18
18
  <Button onClick={openModal}>Open Modal</Button>
@@ -25,7 +25,7 @@
25
25
  </Modal>
26
26
  </Story>
27
27
 
28
- <Story name="Default open">
28
+ <Story name="Default open" asChild>
29
29
  <Modal {defaultOpen} onclose={() => (defaultOpen = false)}>
30
30
  {#snippet Trigger({ openModal })}
31
31
  <Button onClick={openModal}>Open Modal</Button>