@genspectrum/dashboard-components 1.8.2 → 1.9.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.
package/dist/util.d.ts CHANGED
@@ -939,6 +939,22 @@ declare global {
939
939
  }
940
940
 
941
941
 
942
+ declare global {
943
+ interface HTMLElementTagNameMap {
944
+ 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
945
+ }
946
+ }
947
+
948
+
949
+ declare global {
950
+ namespace JSX {
951
+ interface IntrinsicElements {
952
+ 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
953
+ }
954
+ }
955
+ }
956
+
957
+
942
958
  declare global {
943
959
  interface HTMLElementTagNameMap {
944
960
  'gs-genome-data-viewer': GenomeDataViewerComponent;
@@ -1037,7 +1053,7 @@ declare global {
1037
1053
 
1038
1054
  declare global {
1039
1055
  interface HTMLElementTagNameMap {
1040
- 'gs-mutations-over-time': MutationsOverTimeComponent;
1056
+ 'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
1041
1057
  }
1042
1058
  }
1043
1059
 
@@ -1045,7 +1061,7 @@ declare global {
1045
1061
  declare global {
1046
1062
  namespace JSX {
1047
1063
  interface IntrinsicElements {
1048
- 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1064
+ 'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1049
1065
  }
1050
1066
  }
1051
1067
  }
@@ -1053,7 +1069,7 @@ declare global {
1053
1069
 
1054
1070
  declare global {
1055
1071
  interface HTMLElementTagNameMap {
1056
- 'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
1072
+ 'gs-mutations-over-time': MutationsOverTimeComponent;
1057
1073
  }
1058
1074
  }
1059
1075
 
@@ -1061,7 +1077,7 @@ declare global {
1061
1077
  declare global {
1062
1078
  namespace JSX {
1063
1079
  interface IntrinsicElements {
1064
- 'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1080
+ 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1065
1081
  }
1066
1082
  }
1067
1083
  }
@@ -1101,7 +1117,10 @@ declare global {
1101
1117
 
1102
1118
  declare global {
1103
1119
  interface HTMLElementTagNameMap {
1104
- 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1120
+ 'gs-location-filter': LocationFilterComponent;
1121
+ }
1122
+ interface HTMLElementEventMap {
1123
+ [gsEventNames.locationChanged]: LocationChangedEvent;
1105
1124
  }
1106
1125
  }
1107
1126
 
@@ -1109,7 +1128,7 @@ declare global {
1109
1128
  declare global {
1110
1129
  namespace JSX {
1111
1130
  interface IntrinsicElements {
1112
- 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1131
+ 'gs-location-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1113
1132
  }
1114
1133
  }
1115
1134
  }
@@ -1135,25 +1154,6 @@ declare global {
1135
1154
  }
1136
1155
 
1137
1156
 
1138
- declare global {
1139
- interface HTMLElementTagNameMap {
1140
- 'gs-location-filter': LocationFilterComponent;
1141
- }
1142
- interface HTMLElementEventMap {
1143
- [gsEventNames.locationChanged]: LocationChangedEvent;
1144
- }
1145
- }
1146
-
1147
-
1148
- declare global {
1149
- namespace JSX {
1150
- interface IntrinsicElements {
1151
- 'gs-location-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1152
- }
1153
- }
1154
- }
1155
-
1156
-
1157
1157
  declare global {
1158
1158
  interface HTMLElementTagNameMap {
1159
1159
  'gs-text-filter': TextFilterComponent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genspectrum/dashboard-components",
3
- "version": "1.8.2",
3
+ "version": "1.9.1",
4
4
  "description": "GenSpectrum web components for building dashboards",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -64,6 +64,7 @@ export function DownshiftCombobox<Item>({
64
64
  getItemProps,
65
65
  inputValue,
66
66
  closeMenu,
67
+ reset,
67
68
  } = useCombobox({
68
69
  onInputValueChange({ inputValue }) {
69
70
  setInputIsInvalid(false);
@@ -97,7 +98,7 @@ export function DownshiftCombobox<Item>({
97
98
  };
98
99
 
99
100
  const clearInput = () => {
100
- selectItem(null);
101
+ reset();
101
102
  };
102
103
 
103
104
  const buttonRef = useRef(null);
@@ -0,0 +1,129 @@
1
+ import { type FunctionComponent } from 'preact';
2
+ import { type CSSProperties, createPortal } from 'preact/compat';
3
+ import { useState, useRef, useLayoutEffect } from 'preact/hooks';
4
+ import { type JSXInternal } from 'preact/src/jsx';
5
+
6
+ import { type TooltipPosition, TOOLTIP_BASE_STYLES } from './tooltip';
7
+
8
+ export type PortalTooltipProps = {
9
+ content: string | JSXInternal.Element;
10
+ position?: TooltipPosition;
11
+ tooltipStyle?: CSSProperties;
12
+ portalTarget: HTMLElement | null;
13
+ };
14
+
15
+ /**
16
+ * A portal-based tooltip component that renders content in a specified DOM element.
17
+ *
18
+ * Unlike the regular `Tooltip` component, this uses Preact portals to render the tooltip
19
+ * at a specific location in the DOM with fixed positioning. This is useful when:
20
+ * - The tooltip needs to escape overflow constraints from parent containers
21
+ * - You need precise control over the tooltip's rendering location
22
+ * - Parent containers have `overflow: hidden` or other clipping styles
23
+ *
24
+ * **Important:** The `portalTarget` element should still be within the same shadow DOM as the
25
+ * component to ensure proper styling and encapsulation. Typically, this is a container element
26
+ * at the root of your component. Do not use `document.body`.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * const portalTarget = document.getElementById('tooltip-root');
31
+ *
32
+ * <PortalTooltip
33
+ * content="This is a portal tooltip"
34
+ * position="top"
35
+ * portalTarget={portalTarget}
36
+ * >
37
+ * <button>Hover me</button>
38
+ * </PortalTooltip>
39
+ * ```
40
+ */
41
+ const PortalTooltip: FunctionComponent<PortalTooltipProps> = ({
42
+ children,
43
+ content,
44
+ position = 'bottom',
45
+ tooltipStyle,
46
+ portalTarget,
47
+ }) => {
48
+ const [isHovered, setIsHovered] = useState(false);
49
+ const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
50
+ const triggerRef = useRef<HTMLDivElement>(null);
51
+ const tooltipRef = useRef<HTMLDivElement>(null);
52
+
53
+ useLayoutEffect(() => {
54
+ if (isHovered && triggerRef.current !== null && tooltipRef.current !== null) {
55
+ const triggerRect = triggerRef.current.getBoundingClientRect();
56
+ const tooltipRect = tooltipRef.current.getBoundingClientRect();
57
+ const newPosition = calculateTooltipPosition(triggerRect, tooltipRect, position);
58
+ setTooltipPosition(newPosition);
59
+ }
60
+ }, [isHovered, position]);
61
+
62
+ const tooltipContent = (
63
+ <div
64
+ ref={tooltipRef}
65
+ className={`fixed ${TOOLTIP_BASE_STYLES} ${isHovered ? 'visible' : 'invisible'}`}
66
+ style={Object.assign({}, tooltipStyle, { top: tooltipPosition.top, left: tooltipPosition.left })}
67
+ >
68
+ {content}
69
+ </div>
70
+ );
71
+
72
+ return (
73
+ <>
74
+ <div ref={triggerRef} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
75
+ {children}
76
+ </div>
77
+ {portalTarget !== null && createPortal(tooltipContent, portalTarget)}
78
+ </>
79
+ );
80
+ };
81
+
82
+ export default PortalTooltip;
83
+
84
+ function calculateTooltipPosition(
85
+ triggerRect: DOMRect,
86
+ tooltipRect: DOMRect,
87
+ position: TooltipPosition,
88
+ ): { top: number; left: number } {
89
+ const gap = 4;
90
+ let top;
91
+ let left;
92
+
93
+ switch (position) {
94
+ case 'top':
95
+ top = triggerRect.top - tooltipRect.height - gap;
96
+ left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
97
+ break;
98
+ case 'top-start':
99
+ top = triggerRect.top - tooltipRect.height - gap;
100
+ left = triggerRect.left;
101
+ break;
102
+ case 'top-end':
103
+ top = triggerRect.top - tooltipRect.height - gap;
104
+ left = triggerRect.right - tooltipRect.width;
105
+ break;
106
+ case 'bottom':
107
+ top = triggerRect.bottom + gap;
108
+ left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
109
+ break;
110
+ case 'bottom-start':
111
+ top = triggerRect.bottom + gap;
112
+ left = triggerRect.left;
113
+ break;
114
+ case 'bottom-end':
115
+ top = triggerRect.bottom + gap;
116
+ left = triggerRect.right - tooltipRect.width;
117
+ break;
118
+ case 'left':
119
+ top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
120
+ left = triggerRect.left - tooltipRect.width - gap;
121
+ break;
122
+ case 'right':
123
+ top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
124
+ left = triggerRect.right + gap;
125
+ break;
126
+ }
127
+
128
+ return { top, left };
129
+ }
@@ -18,6 +18,38 @@ export type TooltipProps = {
18
18
  tooltipStyle?: CSSProperties;
19
19
  };
20
20
 
21
+ export const TOOLTIP_BASE_STYLES = 'z-10 w-max bg-white p-4 border border-gray-200 rounded-md';
22
+
23
+ /**
24
+ * A simple CSS-based tooltip component that displays content on hover.
25
+ *
26
+ * **Note:** If you need the tooltip to escape overflow constraints or render at a specific
27
+ * location in the DOM (e.g., to avoid clipping by parent containers with `overflow: hidden`),
28
+ * use `PortalTooltip` instead.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <Tooltip content="This is a tooltip" position="top">
33
+ * <button>Hover me</button>
34
+ * </Tooltip>
35
+ * ```
36
+ */
37
+ const Tooltip: FunctionComponent<TooltipProps> = ({ children, content, position = 'bottom', tooltipStyle }) => {
38
+ return (
39
+ <div className={`relative group`}>
40
+ <div>{children}</div>
41
+ <div
42
+ className={`absolute ${TOOLTIP_BASE_STYLES} invisible group-hover:visible ${getPositionCss(position)}`}
43
+ style={tooltipStyle}
44
+ >
45
+ {content}
46
+ </div>
47
+ </div>
48
+ );
49
+ };
50
+
51
+ export default Tooltip;
52
+
21
53
  function getPositionCss(position?: TooltipPosition) {
22
54
  switch (position) {
23
55
  case 'top':
@@ -40,19 +72,3 @@ function getPositionCss(position?: TooltipPosition) {
40
72
  return '';
41
73
  }
42
74
  }
43
-
44
- const Tooltip: FunctionComponent<TooltipProps> = ({ children, content, position = 'bottom', tooltipStyle }) => {
45
- return (
46
- <div className={`relative group`}>
47
- <div>{children}</div>
48
- <div
49
- className={`absolute z-10 w-max bg-white p-4 border border-gray-200 rounded-md invisible group-hover:visible ${getPositionCss(position)}`}
50
- style={tooltipStyle}
51
- >
52
- {content}
53
- </div>
54
- </div>
55
- );
56
- };
57
-
58
- export default Tooltip;
@@ -321,4 +321,76 @@ describe('fetchLineageAutocompleteList', () => {
321
321
  },
322
322
  ]);
323
323
  });
324
+
325
+ test('should include prefix aliases that are missing from lineage tree', async () => {
326
+ lapisRequestMocks.aggregated(
327
+ { fields: [lineageField], ...lapisFilter },
328
+ {
329
+ data: [
330
+ {
331
+ [lineageField]: 'BA.3.2.1',
332
+ count: 1,
333
+ },
334
+ {
335
+ [lineageField]: 'BA.3.2.2',
336
+ count: 2,
337
+ },
338
+ ],
339
+ },
340
+ );
341
+
342
+ lapisRequestMocks.lineageDefinition(
343
+ {
344
+ 'B.1.1.529.3.2': {
345
+ aliases: ['BA.3.2'],
346
+ },
347
+ 'BA.3.2.1': {
348
+ parents: ['B.1.1.529.3.2'],
349
+ aliases: ['B.1.1.529.3.2.1'],
350
+ },
351
+ 'BA.3.2.2': {
352
+ parents: ['B.1.1.529.3.2'],
353
+ aliases: ['B.1.1.529.3.2.2'],
354
+ },
355
+ },
356
+ lineageField,
357
+ );
358
+
359
+ const result = await fetchLineageAutocompleteList({
360
+ lapisUrl: DUMMY_LAPIS_URL,
361
+ lapisField: lineageField,
362
+ lapisFilter,
363
+ });
364
+
365
+ expect(result).to.deep.equal([
366
+ {
367
+ lineage: 'B.1.1.529.3.2',
368
+ count: 0,
369
+ },
370
+ {
371
+ lineage: 'B.1.1.529.3.2*',
372
+ count: 3,
373
+ },
374
+ {
375
+ lineage: 'BA.3.2*',
376
+ count: 3, // Same as B.1.1.529.3.2* (includes .3.2 and .3.2.1)
377
+ },
378
+ {
379
+ lineage: 'BA.3.2.1',
380
+ count: 1,
381
+ },
382
+ {
383
+ lineage: 'BA.3.2.1*',
384
+ count: 1,
385
+ },
386
+ {
387
+ lineage: 'BA.3.2.2',
388
+ count: 2,
389
+ },
390
+ {
391
+ lineage: 'BA.3.2.2*',
392
+ count: 2,
393
+ },
394
+ ]);
395
+ });
324
396
  });
@@ -2,6 +2,10 @@ import { fetchLineageDefinition } from '../../lapisApi/lapisApi';
2
2
  import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
3
3
  import type { LapisFilter } from '../../types';
4
4
 
5
+ /**
6
+ * Generates the autocomplete list for lineage search. It includes lineages with wild cards
7
+ * (i.e. "BA.3.2.1" and "BA.3.2.1*") as well as all prefixes of lineages with an asterisk ("BA.3.2*").
8
+ */
5
9
  export async function fetchLineageAutocompleteList({
6
10
  lapisUrl,
7
11
  lapisField,
@@ -13,31 +17,43 @@ export async function fetchLineageAutocompleteList({
13
17
  lapisFilter?: LapisFilter;
14
18
  signal?: AbortSignal;
15
19
  }): Promise<LineageItem[]> {
16
- const [countsByLineage, lineageTree] = await Promise.all([
20
+ const [countsByLineage, { lineageTree, aliasMapping }] = await Promise.all([
17
21
  getCountsByLineage({
18
22
  lapisUrl,
19
23
  lapisField,
20
24
  lapisFilter,
21
25
  signal,
22
26
  }),
23
- getLineageTree({ lapisUrl, lapisField, signal }),
27
+ getLineageTreeAndAliases({ lapisUrl, lapisField, signal }),
24
28
  ]);
25
29
 
26
- return Array.from(lineageTree.keys())
27
- .sort((a, b) => a.localeCompare(b))
28
- .map((lineage) => {
29
- return [
30
- {
31
- lineage,
32
- count: countsByLineage.get(lineage) ?? 0,
33
- },
34
- {
35
- lineage: `${lineage}*`,
36
- count: getCountsIncludingSublineages(lineage, lineageTree, countsByLineage),
37
- },
38
- ];
39
- })
40
- .flat();
30
+ const prefixToLineage = findMissingPrefixMappings(lineageTree, aliasMapping);
31
+
32
+ // Combine actual lineages with their wildcard versions
33
+ const actualLineageItems = Array.from(lineageTree.keys()).flatMap((lineage) => [
34
+ {
35
+ lineage,
36
+ count: countsByLineage.get(lineage) ?? 0,
37
+ },
38
+ {
39
+ lineage: `${lineage}*`,
40
+ count: getCountsIncludingSublineages(lineage, lineageTree, countsByLineage),
41
+ },
42
+ ]);
43
+
44
+ // Add prefix alias items with wildcard and their counts
45
+ const prefixAliasItems = Array.from(prefixToLineage.entries()).map(([prefix, actualLineage]) => ({
46
+ lineage: `${prefix}*`,
47
+ count: getCountsIncludingSublineages(actualLineage, lineageTree, countsByLineage),
48
+ }));
49
+
50
+ // Combine and sort all items (asterisk before period for same prefix)
51
+ return [...actualLineageItems, ...prefixAliasItems].sort((a, b) => {
52
+ // Replace * with a character that sorts before . in ASCII
53
+ const aKey = a.lineage.replace(/\*/g, ' ');
54
+ const bKey = b.lineage.replace(/\*/g, ' ');
55
+ return aKey.localeCompare(bKey);
56
+ });
41
57
  }
42
58
 
43
59
  export type LineageItem = { lineage: string; count: number };
@@ -61,7 +77,7 @@ async function getCountsByLineage({
61
77
  return new Map<string, number>(countsByLineageArray.map((value) => [value[lapisField], value.count]));
62
78
  }
63
79
 
64
- async function getLineageTree({
80
+ async function getLineageTreeAndAliases({
65
81
  lapisUrl,
66
82
  lapisField,
67
83
  signal,
@@ -73,12 +89,17 @@ async function getLineageTree({
73
89
  const lineageDefinitions = await fetchLineageDefinition({ lapisUrl, lapisField, signal });
74
90
 
75
91
  const lineageTree = new Map<string, { children: string[] }>();
92
+ const aliasMapping = new Map<string, string[]>();
76
93
 
77
94
  Object.entries(lineageDefinitions).forEach(([lineage, definition]) => {
78
95
  if (!lineageTree.has(lineage)) {
79
96
  lineageTree.set(lineage, { children: [] });
80
97
  }
81
98
 
99
+ if (definition.aliases && definition.aliases.length > 0) {
100
+ aliasMapping.set(lineage, definition.aliases);
101
+ }
102
+
82
103
  definition.parents?.forEach((parent) => {
83
104
  const parentChildren = lineageTree.get(parent)?.children;
84
105
 
@@ -88,7 +109,7 @@ async function getLineageTree({
88
109
  });
89
110
  });
90
111
 
91
- return lineageTree;
112
+ return { lineageTree, aliasMapping };
92
113
  }
93
114
 
94
115
  function getCountsIncludingSublineages(
@@ -115,3 +136,44 @@ function getAllDescendants(lineage: string, lineageTree: Map<string, { children:
115
136
 
116
137
  return new Set([...children, ...childrenOfChildren.flatMap((child) => Array.from(child))]);
117
138
  }
139
+
140
+ /**
141
+ * This function finds prefixes (i.e. "BA.3.2" for "BA.3.2.1") that are not in the lineageTree,
142
+ * but do appear as an alias. It returns a reverse mapping for those prefixes, back to a lineage
143
+ * that can be found in the lineageTree (i.e. "BA.3.2" -> "B.1.1.529.3.2").
144
+ */
145
+ function findMissingPrefixMappings(
146
+ lineageTree: Map<string, { children: string[] }>,
147
+ aliasMapping: Map<string, string[]>,
148
+ ): Map<string, string> {
149
+ const lineages = Array.from(lineageTree.keys());
150
+ const lineagesSet = new Set(lineages);
151
+
152
+ // Generate all prefixes for each lineage (e.g., "A.B.1" -> ["A", "A.B", "A.B.1"])
153
+ const allPrefixes = lineages.flatMap((lineage) => {
154
+ const parts = lineage.split('.');
155
+ return parts.map((_, i) => parts.slice(0, i + 1).join('.'));
156
+ });
157
+
158
+ // Find prefixes that are NOT in the actual lineages list
159
+ const missingPrefixes = new Set(allPrefixes.filter((prefix) => !lineagesSet.has(prefix)));
160
+
161
+ // Create reverse alias mapping: alias -> original lineage
162
+ const reverseAliasMapping = new Map<string, string>();
163
+ aliasMapping.forEach((aliases, lineage) => {
164
+ aliases.forEach((alias) => {
165
+ reverseAliasMapping.set(alias, lineage);
166
+ });
167
+ });
168
+
169
+ // Map missing prefixes to their actual lineage names via reverse alias lookup
170
+ const prefixToLineage = new Map<string, string>();
171
+ missingPrefixes.forEach((prefix) => {
172
+ const actualLineage = reverseAliasMapping.get(prefix);
173
+ if (actualLineage) {
174
+ prefixToLineage.set(prefix, actualLineage);
175
+ }
176
+ });
177
+
178
+ return prefixToLineage;
179
+ }
@@ -108,7 +108,7 @@ export const Default: StoryObj<LineageFilterProps> = {
108
108
  const input = await inputField(canvas);
109
109
  await userEvent.clear(input);
110
110
  await userEvent.type(input, 'B.1');
111
- await userEvent.click(canvas.getByRole('option', { name: 'B.1(53802)' }));
111
+ await userEvent.click(canvas.getByRole('option', { name: 'B.1(53,802)' }));
112
112
 
113
113
  await waitFor(() => {
114
114
  return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
@@ -187,6 +187,62 @@ export const WithHideCountsTrue: StoryObj<LineageFilterProps> = {
187
187
  },
188
188
  };
189
189
 
190
+ export const EnterAndClearMultipleTimes: StoryObj<LineageFilterProps> = {
191
+ ...Default,
192
+ play: async ({ canvasElement, step }) => {
193
+ const { canvas, lineageChangedListenerMock } = await prepare(canvasElement, step);
194
+ const input = await inputField(canvas);
195
+ const inputContainer = canvas.getByRole('combobox').parentElement;
196
+ const clearSelectionButton = await canvas.findByLabelText('clear selection');
197
+
198
+ await step('clear field initially', async () => {
199
+ await userEvent.clear(input);
200
+ });
201
+
202
+ await step('enter F in the input field', async () => {
203
+ await userEvent.type(input, 'F');
204
+ });
205
+
206
+ await step('clear selection using clear button', async () => {
207
+ await userEvent.click(clearSelectionButton);
208
+
209
+ await waitFor(() => {
210
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
211
+ pangoLineage: undefined,
212
+ });
213
+ });
214
+ });
215
+
216
+ await step('verify input field is empty after clearing', async () => {
217
+ await expect(input).toHaveValue('');
218
+ });
219
+
220
+ await step('verify no red border after clearing', async () => {
221
+ await expect(inputContainer).not.toHaveClass('input-error');
222
+ });
223
+
224
+ // do it again
225
+
226
+ await step('enter F in the input field again', async () => {
227
+ await userEvent.type(input, 'F');
228
+ });
229
+
230
+ await step('clear selection using clear button again', async () => {
231
+ await userEvent.click(clearSelectionButton);
232
+
233
+ await waitFor(() => {
234
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
235
+ pangoLineage: undefined,
236
+ });
237
+ });
238
+ });
239
+
240
+ await step('verify input field is empty after clearing again', async () => {
241
+ await expect(input).toHaveValue('');
242
+ });
243
+ },
244
+ };
245
+
190
246
  async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRenderer, unknown>) {
191
247
  const canvas = within(canvasElement);
192
248
 
@@ -99,7 +99,7 @@ const LineageSelector = ({
99
99
  formatItemInList={(item: LineageItem) => (
100
100
  <p>
101
101
  <span>{item.lineage}</span>
102
- {!hideCounts && <span className='ml-2 text-gray-500'>({item.count})</span>}
102
+ {!hideCounts && <span className='ml-2 text-gray-500'>({item.count.toLocaleString('en-US')})</span>}
103
103
  </p>
104
104
  )}
105
105
  />
@@ -112,7 +112,9 @@ const LocationSelector = ({
112
112
  <>
113
113
  <p>
114
114
  <span>{item.label}</span>
115
- {!hideCounts && <span className='ml-2 text-gray-500'>({item.count})</span>}
115
+ {!hideCounts && (
116
+ <span className='ml-2 text-gray-500'>({item.count.toLocaleString('en-US')})</span>
117
+ )}
116
118
  </p>
117
119
  <span className='text-sm text-gray-500'>{item.description}</span>
118
120
  </>
@@ -22,7 +22,7 @@ export const MutationsOverTimeGridTooltip: FunctionComponent<MutationsOverTimeGr
22
22
  const dateClass = toTemporalClass(date);
23
23
 
24
24
  return (
25
- <div>
25
+ <div className='text-center'>
26
26
  <p>
27
27
  <span className='font-bold'>{dateClass.englishName()}</span>
28
28
  </p>