@genspectrum/dashboard-components 1.8.2 → 1.9.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.
package/dist/util.d.ts CHANGED
@@ -1005,7 +1005,7 @@ declare global {
1005
1005
 
1006
1006
  declare global {
1007
1007
  interface HTMLElementTagNameMap {
1008
- 'gs-relative-growth-advantage': RelativeGrowthAdvantageComponent;
1008
+ 'gs-aggregate': AggregateComponent;
1009
1009
  }
1010
1010
  }
1011
1011
 
@@ -1013,7 +1013,7 @@ declare global {
1013
1013
  declare global {
1014
1014
  namespace JSX {
1015
1015
  interface IntrinsicElements {
1016
- 'gs-relative-growth-advantage': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1016
+ 'gs-aggregate': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1017
1017
  }
1018
1018
  }
1019
1019
  }
@@ -1021,7 +1021,7 @@ declare global {
1021
1021
 
1022
1022
  declare global {
1023
1023
  interface HTMLElementTagNameMap {
1024
- 'gs-aggregate': AggregateComponent;
1024
+ 'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
1025
1025
  }
1026
1026
  }
1027
1027
 
@@ -1029,7 +1029,7 @@ declare global {
1029
1029
  declare global {
1030
1030
  namespace JSX {
1031
1031
  interface IntrinsicElements {
1032
- 'gs-aggregate': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1032
+ 'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1033
1033
  }
1034
1034
  }
1035
1035
  }
@@ -1037,7 +1037,7 @@ declare global {
1037
1037
 
1038
1038
  declare global {
1039
1039
  interface HTMLElementTagNameMap {
1040
- 'gs-mutations-over-time': MutationsOverTimeComponent;
1040
+ 'gs-relative-growth-advantage': RelativeGrowthAdvantageComponent;
1041
1041
  }
1042
1042
  }
1043
1043
 
@@ -1045,7 +1045,7 @@ declare global {
1045
1045
  declare global {
1046
1046
  namespace JSX {
1047
1047
  interface IntrinsicElements {
1048
- 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1048
+ 'gs-relative-growth-advantage': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1049
1049
  }
1050
1050
  }
1051
1051
  }
@@ -1053,7 +1053,7 @@ declare global {
1053
1053
 
1054
1054
  declare global {
1055
1055
  interface HTMLElementTagNameMap {
1056
- 'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
1056
+ 'gs-sequences-by-location': SequencesByLocationComponent;
1057
1057
  }
1058
1058
  }
1059
1059
 
@@ -1061,7 +1061,7 @@ declare global {
1061
1061
  declare global {
1062
1062
  namespace JSX {
1063
1063
  interface IntrinsicElements {
1064
- 'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1064
+ 'gs-sequences-by-location': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1065
1065
  }
1066
1066
  }
1067
1067
  }
@@ -1069,7 +1069,7 @@ declare global {
1069
1069
 
1070
1070
  declare global {
1071
1071
  interface HTMLElementTagNameMap {
1072
- 'gs-sequences-by-location': SequencesByLocationComponent;
1072
+ 'gs-mutations-over-time': MutationsOverTimeComponent;
1073
1073
  }
1074
1074
  }
1075
1075
 
@@ -1077,7 +1077,7 @@ declare global {
1077
1077
  declare global {
1078
1078
  namespace JSX {
1079
1079
  interface IntrinsicElements {
1080
- 'gs-sequences-by-location': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1080
+ 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1081
1081
  }
1082
1082
  }
1083
1083
  }
@@ -1101,7 +1101,11 @@ declare global {
1101
1101
 
1102
1102
  declare global {
1103
1103
  interface HTMLElementTagNameMap {
1104
- 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1104
+ 'gs-date-range-filter': DateRangeFilterComponent;
1105
+ }
1106
+ interface HTMLElementEventMap {
1107
+ [gsEventNames.dateRangeFilterChanged]: CustomEvent<Record<string, string>>;
1108
+ [gsEventNames.dateRangeOptionChanged]: DateRangeOptionChangedEvent;
1105
1109
  }
1106
1110
  }
1107
1111
 
@@ -1109,7 +1113,7 @@ declare global {
1109
1113
  declare global {
1110
1114
  namespace JSX {
1111
1115
  interface IntrinsicElements {
1112
- 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1116
+ 'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1113
1117
  }
1114
1118
  }
1115
1119
  }
@@ -1117,11 +1121,7 @@ declare global {
1117
1121
 
1118
1122
  declare global {
1119
1123
  interface HTMLElementTagNameMap {
1120
- 'gs-date-range-filter': DateRangeFilterComponent;
1121
- }
1122
- interface HTMLElementEventMap {
1123
- [gsEventNames.dateRangeFilterChanged]: CustomEvent<Record<string, string>>;
1124
- [gsEventNames.dateRangeOptionChanged]: DateRangeOptionChangedEvent;
1124
+ 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1125
1125
  }
1126
1126
  }
1127
1127
 
@@ -1129,7 +1129,7 @@ declare global {
1129
1129
  declare global {
1130
1130
  namespace JSX {
1131
1131
  interface IntrinsicElements {
1132
- 'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1132
+ 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1133
1133
  }
1134
1134
  }
1135
1135
  }
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.0",
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;
@@ -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>
@@ -9,7 +9,8 @@ import { type Deletion, type Substitution } from '../../utils/mutations';
9
9
  import { type Temporal } from '../../utils/temporalClass';
10
10
  import { AnnotatedMutation } from '../components/annotated-mutation';
11
11
  import { type ColorScale, getColorWithinScale, getTextColorForScale } from '../components/color-scale-selector';
12
- import Tooltip, { type TooltipPosition } from '../components/tooltip';
12
+ import PortalTooltip from '../components/portal-tooltip';
13
+ import { type TooltipPosition } from '../components/tooltip';
13
14
  import { formatProportion } from '../shared/table/formatProportion';
14
15
  import { type PageSizes, Pagination } from '../shared/tanstackTable/pagination';
15
16
  import { usePageSizeContext } from '../shared/tanstackTable/pagination-context';
@@ -28,6 +29,7 @@ export interface MutationsOverTimeGridProps {
28
29
  colorScale: ColorScale;
29
30
  sequenceType: SequenceType;
30
31
  pageSizes: PageSizes;
32
+ tooltipPortalTarget: HTMLElement | null;
31
33
  }
32
34
 
33
35
  type RowType = { mutation: Substitution | Deletion; values: (MutationOverTimeMutationValue | undefined)[] };
@@ -37,6 +39,7 @@ const MutationsOverTimeGrid: FunctionComponent<MutationsOverTimeGridProps> = ({
37
39
  colorScale,
38
40
  sequenceType,
39
41
  pageSizes,
42
+ tooltipPortalTarget,
40
43
  }) => {
41
44
  const tableData = useMemo(() => {
42
45
  const allMutations = data.getFirstAxisKeys();
@@ -91,6 +94,7 @@ const MutationsOverTimeGrid: FunctionComponent<MutationsOverTimeGridProps> = ({
91
94
  numberOfColumns,
92
95
  )}
93
96
  colorScale={colorScale}
97
+ tooltipPortalTarget={tooltipPortalTarget}
94
98
  />
95
99
  </div>
96
100
  );
@@ -99,7 +103,7 @@ const MutationsOverTimeGrid: FunctionComponent<MutationsOverTimeGridProps> = ({
99
103
  });
100
104
 
101
105
  return [mutationHeader, ...dateHeaders];
102
- }, [colorScale, data, sequenceType]);
106
+ }, [colorScale, data, sequenceType, tooltipPortalTarget]);
103
107
 
104
108
  const { pageSize } = usePageSizeContext();
105
109
  const table = usePreactTable({
@@ -165,10 +169,10 @@ function styleGridHeader(columnIndex: number, numDateColumns: number) {
165
169
  return { className: 'invisible @[6rem]:visible' };
166
170
  }
167
171
 
168
- function getTooltipPosition(rowIndex: number, rows: number, columnIndex: number, columns: number) {
172
+ function getTooltipPosition(rowIndex: number, rows: number, columnIndex: number, columns: number): TooltipPosition {
169
173
  const tooltipX = rowIndex < rows / 2 || rowIndex < 6 ? 'bottom' : 'top';
170
174
  const tooltipY = columnIndex < columns / 2 ? 'start' : 'end';
171
- return `${tooltipX}-${tooltipY}` as const;
175
+ return `${tooltipX}-${tooltipY}`;
172
176
  }
173
177
 
174
178
  const ProportionCell: FunctionComponent<{
@@ -177,14 +181,16 @@ const ProportionCell: FunctionComponent<{
177
181
  mutation: Substitution | Deletion;
178
182
  tooltipPosition: TooltipPosition;
179
183
  colorScale: ColorScale;
180
- }> = ({ value, mutation, date, tooltipPosition, colorScale }) => {
184
+ tooltipPortalTarget: HTMLElement | null;
185
+ }> = ({ value, mutation, date, tooltipPosition, colorScale, tooltipPortalTarget }) => {
181
186
  const proportion = value?.type === 'belowThreshold' ? undefined : value?.proportion;
182
187
 
183
188
  return (
184
189
  <div className={'py-1 w-full h-full'}>
185
- <Tooltip
190
+ <PortalTooltip
186
191
  content={<MutationsOverTimeGridTooltip mutation={mutation} date={date} value={value} />}
187
192
  position={tooltipPosition}
193
+ portalTarget={tooltipPortalTarget}
188
194
  >
189
195
  <div
190
196
  style={{
@@ -201,7 +207,7 @@ const ProportionCell: FunctionComponent<{
201
207
  </span>
202
208
  )}
203
209
  </div>
204
- </Tooltip>
210
+ </PortalTooltip>
205
211
  </div>
206
212
  );
207
213
  };
@@ -1,5 +1,5 @@
1
1
  import { type FunctionComponent } from 'preact';
2
- import { type Dispatch, type StateUpdater, useMemo, useState, useEffect } from 'preact/hooks';
2
+ import { type Dispatch, type StateUpdater, useMemo, useState, useEffect, useLayoutEffect, useRef } from 'preact/hooks';
3
3
  import z from 'zod';
4
4
 
5
5
  // @ts-expect-error -- uses subpath imports and vite worker import
@@ -142,6 +142,12 @@ const MutationsOverTimeTabs: FunctionComponent<MutationOverTimeTabsProps> = ({
142
142
  overallMutationData,
143
143
  }) => {
144
144
  const tabsRef = useDispatchFinishedLoadingEvent();
145
+ const tooltipPortalTargetRef = useRef<HTMLDivElement>(null);
146
+ const [tooltipPortalTarget, setTooltipPortalTarget] = useState<HTMLDivElement | null>(null);
147
+
148
+ useLayoutEffect(() => {
149
+ setTooltipPortalTarget(tooltipPortalTargetRef.current);
150
+ }, []);
145
151
 
146
152
  const [mutationFilterValue, setMutationFilterValue] = useState<MutationFilter>({
147
153
  textFilter: '',
@@ -198,6 +204,7 @@ const MutationsOverTimeTabs: FunctionComponent<MutationOverTimeTabsProps> = ({
198
204
  colorScale={colorScale}
199
205
  sequenceType={originalComponentProps.sequenceType}
200
206
  pageSizes={originalComponentProps.pageSizes}
207
+ tooltipPortalTarget={tooltipPortalTarget}
201
208
  />
202
209
  ),
203
210
  };
@@ -227,9 +234,11 @@ const MutationsOverTimeTabs: FunctionComponent<MutationOverTimeTabsProps> = ({
227
234
  );
228
235
 
229
236
  return (
230
- <PageSizeContextProvider pageSizes={originalComponentProps.pageSizes}>
231
- <Tabs ref={tabsRef} tabs={tabs} toolbar={toolbar} />
232
- </PageSizeContextProvider>
237
+ <div ref={tooltipPortalTargetRef}>
238
+ <PageSizeContextProvider pageSizes={originalComponentProps.pageSizes}>
239
+ <Tabs ref={tabsRef} tabs={tabs} toolbar={toolbar} />
240
+ </PageSizeContextProvider>
241
+ </div>
233
242
  );
234
243
  };
235
244
 
@@ -100,7 +100,9 @@ const TextSelector = ({
100
100
  return (
101
101
  <p>
102
102
  <span>{item.value}</span>
103
- {!hideCounts && <span className='ml-2 text-gray-500'>({item.count})</span>}
103
+ {!hideCounts && (
104
+ <span className='ml-2 text-gray-500'>({item.count.toLocaleString('en-US')})</span>
105
+ )}
104
106
  </p>
105
107
  );
106
108
  }}
@@ -1,5 +1,5 @@
1
1
  import { type FunctionComponent } from 'preact';
2
- import { type Dispatch, type StateUpdater, useMemo, useState } from 'preact/hooks';
2
+ import { type Dispatch, type StateUpdater, useMemo, useState, useRef } from 'preact/hooks';
3
3
  import z from 'zod';
4
4
 
5
5
  import { computeWastewaterMutationsOverTimeDataPerLocation } from './computeWastewaterMutationsOverTimeDataPerLocation';
@@ -150,6 +150,7 @@ const MutationsOverTimeTabs: FunctionComponent<MutationOverTimeTabsProps> = ({
150
150
  originalComponentProps,
151
151
  }) => {
152
152
  const tabsRef = useDispatchFinishedLoadingEvent();
153
+ const tooltipPortalTargetRef = useRef<HTMLDivElement>(null);
153
154
 
154
155
  const [mutationFilterValue, setMutationFilterValue] = useState<MutationFilter>({
155
156
  textFilter: '',
@@ -176,6 +177,7 @@ const MutationsOverTimeTabs: FunctionComponent<MutationOverTimeTabsProps> = ({
176
177
  colorScale={colorScale}
177
178
  pageSizes={originalComponentProps.pageSizes}
178
179
  sequenceType={originalComponentProps.sequenceType}
180
+ tooltipPortalTarget={tooltipPortalTargetRef.current}
179
181
  />
180
182
  ),
181
183
  })),
@@ -204,9 +206,11 @@ const MutationsOverTimeTabs: FunctionComponent<MutationOverTimeTabsProps> = ({
204
206
  );
205
207
 
206
208
  return (
207
- <PageSizeContextProvider pageSizes={originalComponentProps.pageSizes}>
208
- <Tabs ref={tabsRef} tabs={tabs} toolbar={toolbar} />
209
- </PageSizeContextProvider>
209
+ <div ref={tooltipPortalTargetRef}>
210
+ <PageSizeContextProvider pageSizes={originalComponentProps.pageSizes}>
211
+ <Tabs ref={tabsRef} tabs={tabs} toolbar={toolbar} />
212
+ </PageSizeContextProvider>
213
+ </div>
210
214
  );
211
215
  };
212
216
 
@@ -229,7 +229,7 @@ export const FiresEvent: StoryObj<Required<LineageFilterProps>> = {
229
229
 
230
230
  await step('Enter a valid lineage value', async () => {
231
231
  await userEvent.type(inputField(), 'B.1.1.7*');
232
- await userEvent.click(canvas.getByRole('option', { name: 'B.1.1.7*(677146)' }));
232
+ await userEvent.click(canvas.getByRole('option', { name: 'B.1.1.7*(677,146)' }));
233
233
 
234
234
  await waitFor(() => {
235
235
  return expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
@@ -162,7 +162,7 @@ export const FiresEvents: StoryObj<Required<TextFilterProps>> = {
162
162
  await step('Remove initial value', async () => {
163
163
  await userEvent.click(canvas.getByRole('button', { name: 'clear selection' }));
164
164
 
165
- await expect(listenerMock).toHaveBeenLastCalledWith(
165
+ await expect(listenerMock).toHaveBeenCalledWith(
166
166
  expect.objectContaining({
167
167
  detail: {
168
168
  host: undefined,