@reshape-biotech/design-system 2.3.1 → 2.4.1

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 (60) hide show
  1. package/README.md +0 -3
  2. package/dist/components/activity/Activity.stories.svelte +8 -0
  3. package/dist/components/activity/Activity.test.d.ts +1 -0
  4. package/dist/components/activity/Activity.test.js +89 -0
  5. package/dist/components/avatar/Avatar.test.d.ts +1 -0
  6. package/dist/components/avatar/Avatar.test.js +55 -0
  7. package/dist/components/button/Button.test.d.ts +1 -0
  8. package/dist/components/button/Button.test.js +117 -0
  9. package/dist/components/button/Button.test.svelte +5 -0
  10. package/dist/components/button/Button.test.svelte.d.ts +19 -0
  11. package/dist/components/checkbox/Checkbox.test.d.ts +1 -0
  12. package/dist/components/checkbox/Checkbox.test.js +51 -0
  13. package/dist/components/graphs/matrix/Matrix.test.d.ts +1 -0
  14. package/dist/components/graphs/matrix/Matrix.test.js +256 -0
  15. package/dist/components/graphs/matrix/Matrix.test.svelte +49 -0
  16. package/dist/components/graphs/matrix/Matrix.test.svelte.d.ts +4 -0
  17. package/dist/components/graphs/utils/tooltipFormatter.d.ts +1 -0
  18. package/dist/components/graphs/utils/tooltipFormatter.js +18 -4
  19. package/dist/components/icons/index.d.ts +1 -6
  20. package/dist/components/icons/index.js +7 -0
  21. package/dist/components/input/Input.test.d.ts +1 -0
  22. package/dist/components/input/Input.test.js +35 -0
  23. package/dist/components/label/Label.test.d.ts +1 -0
  24. package/dist/components/label/Label.test.js +29 -0
  25. package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte +1 -1
  26. package/dist/components/manual-cfu-counter/test/ManualCFUCounter.test.d.ts +1 -0
  27. package/dist/components/manual-cfu-counter/test/ManualCFUCounter.test.js +319 -0
  28. package/dist/components/modal/components/modal-overlay.svelte +1 -5
  29. package/dist/components/multi-cfu-counter/MultiCFUCounter.svelte +1 -1
  30. package/dist/components/multi-cfu-counter/test/MultiCFUCounter.test.d.ts +1 -0
  31. package/dist/components/multi-cfu-counter/test/MultiCFUCounter.test.js +320 -0
  32. package/dist/components/progress-circle/ProgressCircle.svelte +1 -1
  33. package/dist/components/select/components/SelectItem.svelte +2 -2
  34. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte +1 -1
  35. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte.d.ts +1 -1
  36. package/dist/components/sjsf-wrappers/SjsfSelectWidgetWrapper.svelte +77 -0
  37. package/dist/components/sjsf-wrappers/SjsfSelectWidgetWrapper.svelte.d.ts +3 -0
  38. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte.d.ts +1 -1
  39. package/dist/components/sjsf-wrappers/sjsfCustomTheme.js +4 -0
  40. package/dist/components/slider/Slider.stories.svelte +5 -0
  41. package/dist/components/slider/Slider.svelte +7 -1
  42. package/dist/components/slider/Slider.svelte.d.ts +1 -0
  43. package/dist/components/stat-card/StatCard.test.d.ts +1 -0
  44. package/dist/components/stat-card/StatCard.test.js +54 -0
  45. package/dist/components/table/Table.test.d.ts +1 -0
  46. package/dist/components/table/Table.test.js +97 -0
  47. package/dist/components/table/Table.test.svelte +37 -0
  48. package/dist/components/table/Table.test.svelte.d.ts +12 -0
  49. package/dist/components/textarea/Textarea.test.d.ts +1 -0
  50. package/dist/components/textarea/Textarea.test.js +90 -0
  51. package/dist/components/toggle-icon-button/ToggleIconButton.test.d.ts +1 -0
  52. package/dist/components/toggle-icon-button/ToggleIconButton.test.js +100 -0
  53. package/dist/components/tooltip/Tooltip.test.d.ts +1 -0
  54. package/dist/components/tooltip/Tooltip.test.js +81 -0
  55. package/dist/notifications.d.ts +4 -1
  56. package/dist/tailwind.preset.d.ts +3 -0
  57. package/dist/tailwind.preset.js +1 -0
  58. package/dist/tokens.d.ts +8 -0
  59. package/dist/tokens.js +4 -0
  60. package/package.json +197 -197
@@ -0,0 +1,49 @@
1
+ <script lang="ts">
2
+ import Matrix, { type MatrixChartProps } from './Matrix.svelte';
3
+
4
+ // Default test data
5
+ const confusionMatrix = [
6
+ [
7
+ { value: 8, row: 0, col: 0 }, // TP (TNTC/TNTC)
8
+ { value: 27, row: 0, col: 1 }, // FN (TNTC/Not TNTC)
9
+ ],
10
+ [
11
+ { value: 2, row: 1, col: 0 }, // FP (Not TNTC/TNTC)
12
+ { value: 123, row: 1, col: 1 }, // TN (Not TNTC/Not TNTC)
13
+ ],
14
+ ];
15
+
16
+ const defaultRowLabels = ['TNTC', 'Not TNTC'];
17
+ const defaultColLabels = ['TNTC', 'Not TNTC'];
18
+
19
+ // Props with default values
20
+ const {
21
+ data = confusionMatrix,
22
+ rowLabels = defaultRowLabels,
23
+ colLabels = defaultColLabels,
24
+ xAxisName = 'Predicted',
25
+ yAxisName = 'Actual',
26
+ width = '500px',
27
+ height = '400px',
28
+ onCellClick = (cell) => console.log('Cell clicked:', cell),
29
+ onCellHover = (cell) => console.log('Cell hover:', cell),
30
+ loading = false,
31
+ invertYAxis = true,
32
+ }: MatrixChartProps = $props();
33
+ </script>
34
+
35
+ <div data-testid="matrix-wrapper" style="width: 100%; height: 400px;">
36
+ <Matrix
37
+ {data}
38
+ {rowLabels}
39
+ {colLabels}
40
+ {xAxisName}
41
+ {yAxisName}
42
+ {width}
43
+ {height}
44
+ {onCellClick}
45
+ {onCellHover}
46
+ {loading}
47
+ {invertYAxis}
48
+ />
49
+ </div>
@@ -0,0 +1,4 @@
1
+ import Matrix, { type MatrixChartProps } from './Matrix.svelte';
2
+ declare const Matrix: import("svelte").Component<MatrixChartProps, {}, "">;
3
+ type Matrix = ReturnType<typeof Matrix>;
4
+ export default Matrix;
@@ -10,5 +10,6 @@ interface TooltipFormatterConfig {
10
10
  getSeriesColor?: (seriesName: string, seriesIndex: number) => string;
11
11
  maxSeriesToShow?: number;
12
12
  }
13
+ export declare function toFixedLocaleString(value: number | undefined | null, fractionDigits?: number, locale?: string | undefined): string;
13
14
  export declare const createTooltipFormatter: (config: TooltipFormatterConfig) => (params: ExtendedCallbackDataParams | ExtendedCallbackDataParams[]) => string;
14
15
  export {};
@@ -1,8 +1,22 @@
1
1
  import { textColor } from '../../../tokens';
2
- const toFixedLocaleString = (value, fractionDigits = 0, locale = undefined) => value?.toLocaleString(locale, {
3
- minimumFractionDigits: fractionDigits,
4
- maximumFractionDigits: fractionDigits,
5
- }) ?? '';
2
+ export function toFixedLocaleString(value, fractionDigits = 0, locale = undefined) {
3
+ if (value === undefined || value === null) {
4
+ return '';
5
+ }
6
+ const formatted = value.toLocaleString(locale, {
7
+ minimumFractionDigits: fractionDigits,
8
+ maximumFractionDigits: fractionDigits,
9
+ });
10
+ const decimalPart = formatted.split('.')[1];
11
+ // preserve meaningful zeros like 5.04
12
+ if (decimalPart && decimalPart.split('').every((digit) => digit === '0')) {
13
+ return value.toLocaleString(locale, {
14
+ minimumFractionDigits: 0,
15
+ maximumFractionDigits: 0,
16
+ });
17
+ }
18
+ return formatted;
19
+ }
6
20
  export const createTooltipFormatter = (config) => {
7
21
  const { maxSeriesToShow = 10 } = config;
8
22
  return (params) => {
@@ -2,18 +2,13 @@ import type { IconComponentProps } from 'phosphor-svelte';
2
2
  import type { Component } from 'svelte';
3
3
  import type { textColor, backgroundColor } from './../../tokens';
4
4
  export type PhosphorIconProps = Component<IconComponentProps, object, ''>;
5
- export type IconName = 'Aperture' | 'ArrowFatLineRight' | 'ArrowCounterClockwise' | 'ArrowRight' | 'ArrowUpRight' | 'ArrowUpLeft' | 'ArrowUUpLeft' | 'Barcode' | 'Bell' | 'BookOpen' | 'BookOpenText' | 'BowlingBall' | 'BugBeetle' | 'Calendar' | 'CalendarBlank' | 'Camera' | 'CameraSlash' | 'CaretDown' | 'CaretLeft' | 'CaretRight' | 'CaretUp' | 'CaretUpDown' | 'ChatTeardropDots' | 'Check' | 'CheckCircle' | 'CheckFat' | 'Circle' | 'CircleDashed' | 'CircleHalf' | 'CirclesFour' | 'CirclesThreePlus' | 'Clock' | 'ClockCountdown' | 'Copy' | 'Crop' | 'Cube' | 'CursorClick' | 'Database' | 'Dna' | 'DotsThree' | 'DotsThreeOutlineVertical' | 'DownloadSimple' | 'Drop' | 'EnvelopeSimple' | 'Eye' | 'Eyedropper' | 'FileCsv' | 'Flag' | 'Flask' | 'Folder' | 'FolderDashed' | 'FolderSimplePlus' | 'FunnelSimple' | 'Gear' | 'GlobeSimple' | 'GlobeSimpleX' | 'GridFour' | 'Hash' | 'House' | 'ImageSquare' | 'ImagesSquare' | 'Info' | 'Lock' | 'LineSegments' | 'List' | 'Link' | 'ListMagnifyingGlass' | 'MagnifyingGlass' | 'MegaphoneSimple' | 'MicrosoftExcelLogo' | 'Moon' | 'Minus' | 'Palette' | 'Pause' | 'Pencil' | 'PencilSimple' | 'Percent' | 'Phone' | 'Plant' | 'Play' | 'Plus' | 'PlusMinus' | 'Ruler' | 'Question' | 'SealCheck' | 'RadioButton' | 'SealQuestion' | 'SealWarning' | 'SelectionAll' | 'Share' | 'SidebarSimple' | 'SkipBack' | 'SkipForward' | 'SignIn' | 'SignOut' | 'SortAscending' | 'Sparkle' | 'SpinnerGap' | 'SquaresFour' | 'Star' | 'Stop' | 'StopCircle' | 'Sun' | 'Table' | 'Tag' | 'Target' | 'TestTube' | 'Trash' | 'TrashSimple' | 'TreeStructure' | 'UserCircle' | 'UserPlus' | 'Video' | 'Warning' | 'WarningCircle' | 'WifiSlash' | 'X';
5
+ export type IconName = 'Aperture' | 'Archive' | 'ArrowFatLineRight' | 'ArrowCounterClockwise' | 'ArrowRight' | 'ArrowUpRight' | 'ArrowUpLeft' | 'ArrowUUpLeft' | 'Barcode' | 'Bell' | 'BookOpen' | 'BookOpenText' | 'BowlingBall' | 'BugBeetle' | 'Calendar' | 'CalendarBlank' | 'Camera' | 'CameraSlash' | 'CaretDown' | 'CaretLeft' | 'CaretRight' | 'CaretUp' | 'CaretUpDown' | 'ChatTeardropDots' | 'Check' | 'CheckCircle' | 'CheckFat' | 'Circle' | 'CircleDashed' | 'CircleHalf' | 'CirclesFour' | 'CirclesThreePlus' | 'Clock' | 'ClockCountdown' | 'Copy' | 'Crop' | 'Cube' | 'CursorClick' | 'CraneTower' | 'Database' | 'Dna' | 'DotsThree' | 'DotsThreeOutlineVertical' | 'DownloadSimple' | 'Drop' | 'EnvelopeSimple' | 'Eye' | 'Eyedropper' | 'EyeSlash' | 'FileCsv' | 'Flag' | 'Flask' | 'Folder' | 'FolderDashed' | 'FolderSimplePlus' | 'FunnelSimple' | 'Gear' | 'GlobeSimple' | 'GlobeSimpleX' | 'GridFour' | 'Hash' | 'House' | 'ImageSquare' | 'ImagesSquare' | 'Info' | 'Lock' | 'LineSegments' | 'List' | 'Link' | 'ListMagnifyingGlass' | 'MagnifyingGlass' | 'MegaphoneSimple' | 'MicrosoftExcelLogo' | 'Moon' | 'Minus' | 'Palette' | 'Pause' | 'Pencil' | 'PencilSimple' | 'Percent' | 'Phone' | 'Plant' | 'Play' | 'Plus' | 'PlusMinus' | 'Ruler' | 'Question' | 'SealCheck' | 'RadioButton' | 'SealQuestion' | 'SealWarning' | 'SelectionAll' | 'Share' | 'SidebarSimple' | 'SkipBack' | 'SkipForward' | 'SignIn' | 'SignOut' | 'SortAscending' | 'Sparkle' | 'SpinnerGap' | 'SquaresFour' | 'Star' | 'Stop' | 'StopCircle' | 'Sun' | 'Table' | 'Tag' | 'Target' | 'TestTube' | 'Trash' | 'TrashSimple' | 'TreeStructure' | 'UserCircle' | 'UserPlus' | 'Video' | 'Warning' | 'WarningCircle' | 'WifiSlash' | 'X';
6
6
  export declare const iconMap: Record<IconName, PhosphorIconProps>;
7
7
  export declare function renderIcon(iconName: keyof typeof iconMap): PhosphorIconProps;
8
8
  export type BackgroundClass = `bg-${keyof typeof backgroundColor}`;
9
9
  export type IconColor = keyof typeof textColor;
10
10
  export type SupportedAnalysisModelIcons = 'pipeline_halos' | 'pipeline_large_colonies' | 'pipeline_microbial_colonies' | 'pipeline_small_colonies' | 'sgs_enteros' | 'general_germination_rate_with_tracking' | 'general_germination_rate_without_tracking' | 'pipeline_insects' | 'pipeline_colony_formation' | 'pipeline_radial_growth' | 'syngenta_health_score_crw' | 'pipeline_seed_germination' | 'syngenta_health_score_faw' | 'syngenta_health_score_sbl' | 'unilever_cfu_count_lowres';
11
11
  export type SupportedPrincipalIcons = 'user' | 'group' | 'organization';
12
- export interface IconComponentProps {
13
- size?: number | string;
14
- color?: string;
15
- weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone';
16
- }
17
12
  export { default as Icon } from './Icon.svelte';
18
13
  export type IconProps = {
19
14
  iconName: IconName;
@@ -1,4 +1,5 @@
1
1
  import Aperture from 'phosphor-svelte/lib/Aperture';
2
+ import Archive from 'phosphor-svelte/lib/Archive';
2
3
  import ArrowFatLineRight from 'phosphor-svelte/lib/ArrowFatLineRight';
3
4
  import ArrowCounterClockwise from 'phosphor-svelte/lib/ArrowCounterClockwise';
4
5
  import ArrowRight from 'phosphor-svelte/lib/ArrowRight';
@@ -35,6 +36,7 @@ import Copy from 'phosphor-svelte/lib/Copy';
35
36
  import Crop from 'phosphor-svelte/lib/Crop';
36
37
  import Cube from 'phosphor-svelte/lib/Cube';
37
38
  import CursorClick from 'phosphor-svelte/lib/CursorClick';
39
+ import CraneTower from 'phosphor-svelte/lib/CraneTower';
38
40
  import Database from 'phosphor-svelte/lib/Database';
39
41
  import Dna from 'phosphor-svelte/lib/Dna';
40
42
  import DotsThree from 'phosphor-svelte/lib/DotsThree';
@@ -44,6 +46,7 @@ import Drop from 'phosphor-svelte/lib/Drop';
44
46
  import EnvelopeSimple from 'phosphor-svelte/lib/EnvelopeSimple';
45
47
  import Eye from 'phosphor-svelte/lib/Eye';
46
48
  import Eyedropper from 'phosphor-svelte/lib/Eyedropper';
49
+ import EyeSlash from 'phosphor-svelte/lib/EyeSlash';
47
50
  import FileCsv from 'phosphor-svelte/lib/FileCsv';
48
51
  import Flag from 'phosphor-svelte/lib/Flag';
49
52
  import Flask from 'phosphor-svelte/lib/Flask';
@@ -110,6 +113,7 @@ import TrashSimple from 'phosphor-svelte/lib/TrashSimple';
110
113
  import TreeStructure from 'phosphor-svelte/lib/TreeStructure';
111
114
  import UserCircle from 'phosphor-svelte/lib/UserCircle';
112
115
  import UserPlus from 'phosphor-svelte/lib/UserPlus';
116
+ import User from 'phosphor-svelte/lib/User';
113
117
  import Video from 'phosphor-svelte/lib/Video';
114
118
  import Warning from 'phosphor-svelte/lib/Warning';
115
119
  import WarningCircle from 'phosphor-svelte/lib/WarningCircle';
@@ -117,6 +121,7 @@ import WifiSlash from 'phosphor-svelte/lib/WifiSlash';
117
121
  import X from 'phosphor-svelte/lib/X';
118
122
  export const iconMap = {
119
123
  Aperture,
124
+ Archive,
120
125
  ArrowFatLineRight,
121
126
  ArrowCounterClockwise,
122
127
  ArrowRight,
@@ -153,6 +158,7 @@ export const iconMap = {
153
158
  Crop,
154
159
  Cube,
155
160
  CursorClick,
161
+ CraneTower,
156
162
  Database,
157
163
  Dna,
158
164
  DotsThree,
@@ -162,6 +168,7 @@ export const iconMap = {
162
168
  EnvelopeSimple,
163
169
  Eye,
164
170
  Eyedropper,
171
+ EyeSlash,
165
172
  FileCsv,
166
173
  Flag,
167
174
  Flask,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { render } from '@testing-library/svelte';
2
+ import { describe, it, expect } from 'vitest';
3
+ import Input from './Input.svelte';
4
+ describe('Input', () => {
5
+ it('should render input with text type when no type is provided', () => {
6
+ const { getByRole } = render(Input, {
7
+ props: {
8
+ value: 'foo',
9
+ },
10
+ });
11
+ const input = getByRole('textbox');
12
+ expect(input.type).toBe('text');
13
+ });
14
+ it('should render label when provided', () => {
15
+ const { getByLabelText } = render(Input, {
16
+ props: {
17
+ value: 'foo',
18
+ label: 'bar',
19
+ },
20
+ });
21
+ const input = getByLabelText('bar');
22
+ expect(input.value).toBe('foo');
23
+ });
24
+ it('should include placeholder when provided, and contain placeholder class', () => {
25
+ const { getByRole } = render(Input, {
26
+ props: {
27
+ placeholder: 'foo',
28
+ value: 'foo',
29
+ },
30
+ });
31
+ const input = getByRole('textbox');
32
+ expect(input.placeholder).toBe('foo');
33
+ expect(input.classList).toContain('has-placeholder');
34
+ });
35
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { render, screen } from '@testing-library/svelte';
2
+ import Label from './Label.svelte';
3
+ import { describe, it, expect } from 'vitest';
4
+ describe('Label component', () => {
5
+ it('renders the label text', () => {
6
+ const labelText = 'Test Label';
7
+ render(Label, { props: { text: labelText, forId: 'testId' } });
8
+ expect(screen.getByText(labelText)).toBeInTheDocument();
9
+ });
10
+ it('renders the required indicator when required is true', () => {
11
+ render(Label, { props: { text: 'Required Label', forId: 'reqId', required: true } });
12
+ expect(screen.getByText('*')).toBeInTheDocument();
13
+ expect(screen.getByText('*')).toHaveClass('ml-0.5 text-danger');
14
+ });
15
+ it('does not render the required indicator when required is false or not provided', () => {
16
+ render(Label, { props: { text: 'Optional Label', forId: 'optId' } });
17
+ expect(screen.queryByText('*')).not.toBeInTheDocument();
18
+ });
19
+ it('applies the forId to the for attribute', () => {
20
+ const testId = 'my-input';
21
+ render(Label, { props: { text: 'Label for Input', forId: testId } });
22
+ expect(screen.getByText('Label for Input').closest('label')).toHaveAttribute('for', testId);
23
+ });
24
+ it('applies custom classes', () => {
25
+ const customClass = 'my-custom-class';
26
+ render(Label, { props: { text: 'Custom Class Label', forId: 'customId', class: customClass } });
27
+ expect(screen.getByText('Custom Class Label').closest('label')).toHaveClass(customClass);
28
+ });
29
+ });
@@ -534,7 +534,7 @@
534
534
  {@render ZoomControls()}
535
535
  {/if}
536
536
 
537
- <!--
537
+ <!--
538
538
  We need to use SVG for this interactive component.
539
539
  The SVG element is treated as a canvas for clicking, panning, and zooming.
540
540
  We add accessibility attributes to make it more accessible despite the interactive nature.
@@ -0,0 +1,319 @@
1
+ import { render, fireEvent, waitFor, findByText, findByRole, getByTestId, } from '@testing-library/svelte';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { tick } from 'svelte';
4
+ import ManualCFUCounter from '../ManualCFUCounter.svelte';
5
+ import ManualCFUCounterTestWrapper from './ManualCFUCounterTestWrapper.svelte';
6
+ class MockImage {
7
+ width = 464;
8
+ height = 464;
9
+ src = '';
10
+ onload = null;
11
+ complete = false;
12
+ constructor() {
13
+ // Set a timeout to simulate async image loading
14
+ setTimeout(() => {
15
+ this.complete = true;
16
+ if (this.onload)
17
+ this.onload();
18
+ }, 0);
19
+ }
20
+ }
21
+ if (typeof global !== 'undefined') {
22
+ global.Image = MockImage;
23
+ // mock resizeobserver which isn't available in gh actions environment
24
+ class MockResizeObserver {
25
+ observe = vi.fn();
26
+ unobserve = vi.fn();
27
+ disconnect = vi.fn();
28
+ }
29
+ global.ResizeObserver = MockResizeObserver;
30
+ }
31
+ const simulateImageLoad = async () => {
32
+ await tick();
33
+ };
34
+ describe('ManualCFUCounter Component', () => {
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ });
38
+ it('renders with default props', async () => {
39
+ const { container } = render(ManualCFUCounter, {
40
+ props: {
41
+ imageUrl: 'test-image.jpg',
42
+ },
43
+ });
44
+ await simulateImageLoad();
45
+ const svg = container.querySelector('svg');
46
+ const image = container.querySelector('image');
47
+ const dotsGroup = container.querySelector('#dots');
48
+ expect(svg).toBeTruthy();
49
+ expect(image).toBeTruthy();
50
+ expect(dotsGroup).toBeTruthy();
51
+ const mainSvg = container.querySelector('svg[role="application"]');
52
+ expect(mainSvg).toBeTruthy();
53
+ expect(mainSvg?.getAttribute('role')).toBe('application');
54
+ expect(mainSvg?.getAttribute('aria-label')).toContain('CFU Counter');
55
+ });
56
+ it('displays action buttons when marks are present', async () => {
57
+ const { container } = render(ManualCFUCounter, {
58
+ props: {
59
+ imageUrl: 'test-image.jpg',
60
+ marks: [{ x: 10, y: 10 }],
61
+ },
62
+ });
63
+ await simulateImageLoad();
64
+ const undoButton = await findByRole(container, 'button', { name: /Undo last mark/i });
65
+ const clearButton = await findByText(container, 'Clear all');
66
+ expect(undoButton).toBeTruthy();
67
+ expect(clearButton).toBeTruthy();
68
+ });
69
+ it('does not display action buttons when hideMarkers is true', async () => {
70
+ const { queryByAltText, queryByText } = render(ManualCFUCounter, {
71
+ props: {
72
+ imageUrl: 'test-image.jpg',
73
+ hideMarkers: true,
74
+ marks: [{ x: 10, y: 10 }],
75
+ },
76
+ });
77
+ await simulateImageLoad();
78
+ const undoButton = queryByAltText('Undo last mark');
79
+ const clearButton = queryByText('Clear all');
80
+ expect(undoButton).toBeFalsy();
81
+ expect(clearButton).toBeFalsy();
82
+ });
83
+ it('does not display action buttons when disabled is true', async () => {
84
+ const { queryByAltText, queryByText } = render(ManualCFUCounter, {
85
+ props: {
86
+ imageUrl: 'test-image.jpg',
87
+ disabled: true,
88
+ marks: [{ x: 10, y: 10 }],
89
+ },
90
+ });
91
+ await simulateImageLoad();
92
+ const undoButton = queryByAltText('Undo last mark');
93
+ const clearButton = queryByText('Clear all');
94
+ expect(undoButton).toBeFalsy();
95
+ expect(clearButton).toBeFalsy();
96
+ });
97
+ it('adds cursor-not-allowed class when disabled', async () => {
98
+ const { container } = render(ManualCFUCounter, {
99
+ props: {
100
+ imageUrl: 'test-image.jpg',
101
+ disabled: true,
102
+ },
103
+ });
104
+ await simulateImageLoad();
105
+ const svg = container.querySelector('svg');
106
+ expect(svg?.classList.contains('cursor-not-allowed')).toBe(true);
107
+ });
108
+ it('renders with initial marks provided via marks prop', async () => {
109
+ const initialMarksData = [
110
+ { x: 100, y: 100 },
111
+ { x: 200, y: 200 },
112
+ ];
113
+ const { container } = render(ManualCFUCounter, {
114
+ props: {
115
+ imageUrl: 'test-image.jpg',
116
+ marks: initialMarksData,
117
+ },
118
+ });
119
+ await simulateImageLoad();
120
+ await tick();
121
+ const marksCount = container.querySelector('[data-testid="marks-count"]');
122
+ expect(marksCount?.textContent).toBe('2');
123
+ const markers = container.querySelectorAll('[data-testid^="marker-"]');
124
+ expect(markers.length).toBe(2);
125
+ const circles = container.querySelectorAll('circle');
126
+ expect(circles.length).toBe(2);
127
+ expect(circles[0].getAttribute('cx')).toBe('100');
128
+ expect(circles[0].getAttribute('cy')).toBe('100');
129
+ expect(circles[1].getAttribute('cx')).toBe('200');
130
+ expect(circles[1].getAttribute('cy')).toBe('200');
131
+ });
132
+ it('simulates adding a marker via direct click', async () => {
133
+ const mockOnClick = vi.fn();
134
+ const { container } = render(ManualCFUCounter, {
135
+ props: {
136
+ imageUrl: 'test-image.jpg',
137
+ onclick: mockOnClick,
138
+ },
139
+ });
140
+ await simulateImageLoad();
141
+ const marksCountBefore = container.querySelector('[data-testid="marks-count"]');
142
+ expect(marksCountBefore?.textContent).toBe('0');
143
+ const svg = container.querySelector('svg');
144
+ expect(svg).toBeTruthy();
145
+ if (svg) {
146
+ const viewportElement = container.querySelector('#viewport');
147
+ expect(viewportElement).toBeTruthy();
148
+ if (viewportElement) {
149
+ await fireEvent.click(viewportElement, {
150
+ clientX: 100,
151
+ clientY: 100,
152
+ });
153
+ }
154
+ await waitFor(() => {
155
+ expect(mockOnClick).toHaveBeenCalledTimes(1);
156
+ });
157
+ const callArgs = mockOnClick.mock.calls[0];
158
+ const addedMarks = callArgs[1];
159
+ expect(addedMarks.length).toBe(1);
160
+ expect(addedMarks[0].x).toBeCloseTo(100);
161
+ expect(addedMarks[0].y).toBeCloseTo(100);
162
+ await waitFor(() => {
163
+ const marksCountAfter = container.querySelector('[data-testid="marks-count"]');
164
+ expect(marksCountAfter?.textContent).toBe('1');
165
+ });
166
+ const marker = container.querySelector('[data-testid="marker-1"]');
167
+ expect(marker).toBeTruthy();
168
+ }
169
+ });
170
+ it('simulates adding multiple markers via clicks', async () => {
171
+ const mockOnClick = vi.fn();
172
+ const { container } = render(ManualCFUCounter, {
173
+ props: {
174
+ imageUrl: 'test-image.jpg',
175
+ onclick: mockOnClick,
176
+ },
177
+ });
178
+ await simulateImageLoad();
179
+ let marksCount = container.querySelector('[data-testid="marks-count"]');
180
+ expect(marksCount?.textContent).toBe('0');
181
+ const svg = container.querySelector('svg');
182
+ expect(svg).toBeTruthy();
183
+ if (svg) {
184
+ const viewportElement = container.querySelector('#viewport');
185
+ expect(viewportElement).toBeTruthy();
186
+ if (viewportElement) {
187
+ await fireEvent.click(viewportElement, {
188
+ clientX: 100,
189
+ clientY: 100,
190
+ });
191
+ await waitFor(() => {
192
+ expect(mockOnClick).toHaveBeenCalledTimes(1);
193
+ });
194
+ let firstCallArgs = mockOnClick.mock.calls[0];
195
+ let firstMarks = firstCallArgs[1];
196
+ expect(firstMarks.length).toBe(1);
197
+ await fireEvent.click(viewportElement, {
198
+ clientX: 200,
199
+ clientY: 200,
200
+ });
201
+ }
202
+ await waitFor(() => {
203
+ expect(mockOnClick).toHaveBeenCalledTimes(2);
204
+ });
205
+ let secondCallArgs = mockOnClick.mock.calls[1];
206
+ let secondMarks = secondCallArgs[1];
207
+ expect(secondMarks.length).toBe(2);
208
+ const markers = container.querySelectorAll('[data-testid^="marker-"]');
209
+ expect(markers.length).toBe(2);
210
+ const circles = container.querySelectorAll('circle');
211
+ expect(circles.length).toBe(2);
212
+ expect(mockOnClick).toHaveBeenCalledTimes(2);
213
+ }
214
+ });
215
+ it('simulates clearing marks through the reset button', async () => {
216
+ const mockOnClick = vi.fn();
217
+ const initialMarksData = [
218
+ { x: 100, y: 100 },
219
+ { x: 200, y: 200 },
220
+ ];
221
+ const { container, component } = render(ManualCFUCounterTestWrapper, {
222
+ props: {
223
+ initialTestMarks: initialMarksData,
224
+ onclick: mockOnClick,
225
+ },
226
+ });
227
+ await simulateImageLoad();
228
+ await tick();
229
+ let marksCount = getByTestId(container, 'marks-count');
230
+ expect(marksCount?.textContent).toBe('2');
231
+ const clearButton = await findByText(container, 'Clear all');
232
+ expect(clearButton).toBeTruthy();
233
+ if (clearButton) {
234
+ await fireEvent.click(clearButton);
235
+ await tick();
236
+ const wrapperInstance = component;
237
+ const currentWrapperMarks = wrapperInstance.getCurrentMarks();
238
+ expect(currentWrapperMarks.length).toBe(0);
239
+ marksCount = getByTestId(container, 'wrapper-marks-count');
240
+ expect(marksCount?.textContent).toBe('0');
241
+ const innerMarksCount = getByTestId(container, 'marks-count');
242
+ expect(innerMarksCount?.textContent).toBe('0');
243
+ const markers = container.querySelectorAll('[data-testid^="marker-"]');
244
+ expect(markers.length).toBe(0);
245
+ expect(mockOnClick).not.toHaveBeenCalledWith();
246
+ }
247
+ });
248
+ it('properly constrains pan to prevent moving outside container', async () => {
249
+ const { container } = render(ManualCFUCounter, {
250
+ props: {
251
+ imageUrl: 'test-image.jpg',
252
+ },
253
+ });
254
+ await simulateImageLoad();
255
+ const viewport = container.querySelector('#viewport');
256
+ expect(viewport).toBeTruthy();
257
+ expect(viewport?.getAttribute('transform')).toBe('translate(0, 0) scale(1)');
258
+ });
259
+ it('properly sets up keyboard accessibility attributes', async () => {
260
+ const { container } = render(ManualCFUCounter, {
261
+ props: {
262
+ imageUrl: 'test-image.jpg',
263
+ },
264
+ });
265
+ await simulateImageLoad();
266
+ const svg = container.querySelector('svg[role="application"]');
267
+ expect(svg?.getAttribute('tabindex')).toBe('0');
268
+ expect(svg?.getAttribute('role')).toBe('application');
269
+ expect(svg?.getAttribute('aria-label')).toContain('CFU Counter');
270
+ });
271
+ it('simulates undoing a marker addition via the undo button', async () => {
272
+ const mockOnClick = vi.fn();
273
+ const initialMarksData = [{ x: 50, y: 50 }];
274
+ const { container, component } = render(ManualCFUCounterTestWrapper, {
275
+ props: {
276
+ initialTestMarks: initialMarksData,
277
+ onclick: mockOnClick,
278
+ },
279
+ });
280
+ await simulateImageLoad();
281
+ await tick();
282
+ let marksCount = getByTestId(container, 'marks-count');
283
+ expect(marksCount?.textContent).toBe('1');
284
+ const svg = container.querySelector('svg');
285
+ expect(svg).toBeTruthy();
286
+ if (svg) {
287
+ const viewportElement = container.querySelector('#viewport');
288
+ expect(viewportElement).toBeTruthy();
289
+ if (viewportElement) {
290
+ await fireEvent.click(viewportElement, { clientX: 100, clientY: 100 });
291
+ }
292
+ await tick();
293
+ const wrapperInstance = component;
294
+ const marksAfterClick = wrapperInstance.getCurrentMarks();
295
+ expect(marksAfterClick.length).toBe(2);
296
+ mockOnClick.mockClear();
297
+ }
298
+ const undoButton = await findByRole(container, 'button', { name: /Undo last mark/i });
299
+ expect(undoButton).toBeTruthy();
300
+ if (undoButton) {
301
+ await fireEvent.click(undoButton);
302
+ await tick();
303
+ const wrapperInstance = component;
304
+ const marksAfterUndo = wrapperInstance.getCurrentMarks();
305
+ expect(marksAfterUndo.length).toBe(1);
306
+ expect(marksAfterUndo[0].x).toBeCloseTo(50);
307
+ expect(marksAfterUndo[0].y).toBeCloseTo(50);
308
+ await waitFor(() => {
309
+ const innerMarksCount = getByTestId(container, 'marks-count');
310
+ expect(innerMarksCount?.textContent).toBe('1');
311
+ const wrapperMarksCount = getByTestId(container, 'wrapper-marks-count');
312
+ expect(wrapperMarksCount?.textContent).toBe('1');
313
+ });
314
+ const markers = container.querySelectorAll('[data-testid^="marker-"]');
315
+ expect(markers.length).toBe(1);
316
+ expect(mockOnClick).not.toHaveBeenCalled();
317
+ }
318
+ });
319
+ });
@@ -9,11 +9,7 @@
9
9
  <Dialog.Overlay forceMount {...restProps}>
10
10
  {#snippet child({ props, open })}
11
11
  {#if open}
12
- <div
13
- {...props}
14
- class="fixed inset-0 z-50 bg-overlay backdrop-blur-sm"
15
- transition:fade={{ duration: 200 }}
16
- >
12
+ <div {...props} class="fixed inset-0 z-50 bg-overlay" transition:fade={{ duration: 200 }}>
17
13
  {#if children}
18
14
  {@render children()}
19
15
  {/if}
@@ -623,7 +623,7 @@
623
623
  {@render ZoomControls()}
624
624
  {/if}
625
625
 
626
- <!--
626
+ <!--
627
627
  We need to use SVG for this interactive component.
628
628
  The SVG element is treated as a canvas for clicking, panning, and zooming.
629
629
  We add accessibility attributes to make it more accessible despite the interactive nature.