@genspectrum/dashboard-components 1.9.2 → 1.10.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
@@ -135,6 +135,7 @@ export declare const gsEventNames: {
135
135
  readonly dateRangeOptionChanged: "gs-date-range-option-changed";
136
136
  readonly mutationFilterChanged: "gs-mutation-filter-changed";
137
137
  readonly lineageFilterChanged: "gs-lineage-filter-changed";
138
+ readonly lineageFilterMultiChanged: "gs-lineage-filter-multi-changed";
138
139
  readonly locationChanged: "gs-location-changed";
139
140
  readonly textFilterChanged: "gs-text-filter-changed";
140
141
  readonly numberRangeFilterChanged: "gs-number-range-filter-changed";
@@ -939,22 +940,6 @@ declare global {
939
940
  }
940
941
 
941
942
 
942
- declare global {
943
- interface HTMLElementTagNameMap {
944
- 'gs-genome-data-viewer': GenomeDataViewerComponent;
945
- }
946
- }
947
-
948
-
949
- declare global {
950
- namespace JSX {
951
- interface IntrinsicElements {
952
- 'gs-genome-data-viewer': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
953
- }
954
- }
955
- }
956
-
957
-
958
943
  declare global {
959
944
  interface HTMLElementTagNameMap {
960
945
  'gs-mutation-comparison-component': MutationComparisonComponent;
@@ -1053,7 +1038,7 @@ declare global {
1053
1038
 
1054
1039
  declare global {
1055
1040
  interface HTMLElementTagNameMap {
1056
- 'gs-mutations-over-time': MutationsOverTimeComponent;
1041
+ 'gs-sequences-by-location': SequencesByLocationComponent;
1057
1042
  }
1058
1043
  }
1059
1044
 
@@ -1061,7 +1046,7 @@ declare global {
1061
1046
  declare global {
1062
1047
  namespace JSX {
1063
1048
  interface IntrinsicElements {
1064
- 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1049
+ 'gs-sequences-by-location': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1065
1050
  }
1066
1051
  }
1067
1052
  }
@@ -1069,7 +1054,7 @@ declare global {
1069
1054
 
1070
1055
  declare global {
1071
1056
  interface HTMLElementTagNameMap {
1072
- 'gs-sequences-by-location': SequencesByLocationComponent;
1057
+ 'gs-mutations-over-time': MutationsOverTimeComponent;
1073
1058
  }
1074
1059
  }
1075
1060
 
@@ -1077,7 +1062,7 @@ declare global {
1077
1062
  declare global {
1078
1063
  namespace JSX {
1079
1064
  interface IntrinsicElements {
1080
- 'gs-sequences-by-location': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1065
+ 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1081
1066
  }
1082
1067
  }
1083
1068
  }
@@ -1085,7 +1070,7 @@ declare global {
1085
1070
 
1086
1071
  declare global {
1087
1072
  interface HTMLElementTagNameMap {
1088
- 'gs-statistics': StatisticsComponent;
1073
+ 'gs-genome-data-viewer': GenomeDataViewerComponent;
1089
1074
  }
1090
1075
  }
1091
1076
 
@@ -1093,7 +1078,7 @@ declare global {
1093
1078
  declare global {
1094
1079
  namespace JSX {
1095
1080
  interface IntrinsicElements {
1096
- 'gs-statistics': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1081
+ 'gs-genome-data-viewer': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1097
1082
  }
1098
1083
  }
1099
1084
  }
@@ -1101,7 +1086,7 @@ declare global {
1101
1086
 
1102
1087
  declare global {
1103
1088
  interface HTMLElementTagNameMap {
1104
- 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1089
+ 'gs-statistics': StatisticsComponent;
1105
1090
  }
1106
1091
  }
1107
1092
 
@@ -1109,7 +1094,7 @@ declare global {
1109
1094
  declare global {
1110
1095
  namespace JSX {
1111
1096
  interface IntrinsicElements {
1112
- 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1097
+ 'gs-statistics': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1113
1098
  }
1114
1099
  }
1115
1100
  }
@@ -1192,12 +1177,33 @@ declare global {
1192
1177
  }
1193
1178
 
1194
1179
 
1180
+ declare global {
1181
+ interface HTMLElementTagNameMap {
1182
+ 'gs-number-range-filter': NumberRangeFilterComponent;
1183
+ }
1184
+ interface HTMLElementEventMap {
1185
+ [gsEventNames.numberRangeFilterChanged]: NumberRangeFilterChangedEvent;
1186
+ [gsEventNames.numberRangeValueChanged]: NumberRangeValueChangedEvent;
1187
+ }
1188
+ }
1189
+
1190
+
1191
+ declare global {
1192
+ namespace JSX {
1193
+ interface IntrinsicElements {
1194
+ 'gs-number-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+
1195
1200
  declare global {
1196
1201
  interface HTMLElementTagNameMap {
1197
1202
  'gs-lineage-filter': LineageFilterComponent;
1198
1203
  }
1199
1204
  interface HTMLElementEventMap {
1200
1205
  [gsEventNames.lineageFilterChanged]: LineageFilterChangedEvent;
1206
+ [gsEventNames.lineageFilterMultiChanged]: LineageMultiFilterChangedEvent;
1201
1207
  }
1202
1208
  }
1203
1209
 
@@ -1213,11 +1219,7 @@ declare global {
1213
1219
 
1214
1220
  declare global {
1215
1221
  interface HTMLElementTagNameMap {
1216
- 'gs-number-range-filter': NumberRangeFilterComponent;
1217
- }
1218
- interface HTMLElementEventMap {
1219
- [gsEventNames.numberRangeFilterChanged]: NumberRangeFilterChangedEvent;
1220
- [gsEventNames.numberRangeValueChanged]: NumberRangeValueChangedEvent;
1222
+ 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1221
1223
  }
1222
1224
  }
1223
1225
 
@@ -1225,7 +1227,7 @@ declare global {
1225
1227
  declare global {
1226
1228
  namespace JSX {
1227
1229
  interface IntrinsicElements {
1228
- 'gs-number-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1230
+ 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1229
1231
  }
1230
1232
  }
1231
1233
  }
package/dist/util.js CHANGED
@@ -1,4 +1,4 @@
1
- import { D, a, L, N, b, T, d, g, m, v } from "./NumberRangeFilterChangedEvent-BnPI-Asz.js";
1
+ import { D, a, L, N, b, T, d, g, m, v } from "./NumberRangeFilterChangedEvent-Cdtcp9YL.js";
2
2
  export {
3
3
  D as DateRangeOptionChangedEvent,
4
4
  a as LineageFilterChangedEvent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genspectrum/dashboard-components",
3
- "version": "1.9.2",
3
+ "version": "1.10.1",
4
4
  "description": "GenSpectrum web components for building dashboards",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -1,4 +1,4 @@
1
- import { useCombobox } from 'downshift/preact';
1
+ import { useCombobox, useMultipleSelection } from 'downshift/preact';
2
2
  import { type ComponentChild } from 'preact';
3
3
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
4
4
 
@@ -43,17 +43,7 @@ export function DownshiftCombobox<Item>({
43
43
  divRef.current?.dispatchEvent(createEvent(item));
44
44
  };
45
45
 
46
- const shadowRoot = divRef.current?.shadowRoot ?? undefined;
47
-
48
- const environment =
49
- shadowRoot !== undefined
50
- ? {
51
- addEventListener: window.addEventListener.bind(window),
52
- removeEventListener: window.removeEventListener.bind(window),
53
- document: shadowRoot.ownerDocument,
54
- Node: window.Node,
55
- }
56
- : undefined;
46
+ const environment = useShadowEnvironment(divRef);
57
47
 
58
48
  const {
59
49
  isOpen,
@@ -120,44 +110,284 @@ export function DownshiftCombobox<Item>({
120
110
  {...getInputProps()}
121
111
  onBlur={onInputBlur}
122
112
  />
123
- <button
124
- aria-label='clear selection'
125
- className={`px-2 ${inputValue === '' && 'hidden'}`}
126
- type='button'
127
- onClick={clearInput}
128
- tabIndex={-1}
129
- >
130
- <DeleteIcon />
131
- </button>
132
- <button
133
- aria-label='toggle menu'
134
- className='px-2'
135
- type='button'
136
- {...getToggleButtonProps()}
137
- ref={buttonRef}
138
- >
139
- {isOpen ? <>↑</> : <>↓</>}
140
- </button>
113
+ <ClearButton onClick={clearInput} isHidden={inputValue === ''} />
114
+ <ToggleButton isOpen={isOpen} buttonRef={buttonRef} getToggleButtonProps={getToggleButtonProps} />
141
115
  </div>
142
116
  </div>
143
- <ul
144
- className={`absolute bg-white mt-1 shadow-md max-h-80 overflow-scroll z-10 w-full min-w-32 ${isOpen ? '' : 'hidden'}`}
145
- {...getMenuProps()}
146
- >
147
- {items.length > 0 ? (
148
- items.map((item, index) => (
149
- <li
150
- className={`${highlightedIndex === index ? 'bg-blue-300' : ''} ${selectedItem !== null && itemToString(selectedItem) === itemToString(item) ? 'font-bold' : ''} py-2 px-3 shadow-xs`}
151
- key={itemToString(item)}
152
- {...getItemProps({ item, index })}
117
+ <DropdownMenu
118
+ isOpen={isOpen}
119
+ getMenuProps={getMenuProps}
120
+ items={items}
121
+ highlightedIndex={highlightedIndex}
122
+ getItemProps={getItemProps}
123
+ formatItemInList={formatItemInList}
124
+ itemToString={itemToString}
125
+ selectedItem={selectedItem}
126
+ emptyMessage='No elements to select.'
127
+ />
128
+ </div>
129
+ );
130
+ }
131
+
132
+ export function DownshiftMultiCombobox<Item>({
133
+ allItems,
134
+ value,
135
+ filterItemsByInputValue,
136
+ createEvent,
137
+ itemToString,
138
+ placeholderText,
139
+ formatItemInList,
140
+ formatSelectedItem,
141
+ inputClassName = '',
142
+ }: {
143
+ allItems: Item[];
144
+ value: Item[];
145
+ filterItemsByInputValue: (item: Item, value: string) => boolean;
146
+ createEvent: (items: Item[]) => CustomEvent;
147
+ itemToString: (item: Item | undefined | null) => string;
148
+ placeholderText?: string;
149
+ formatItemInList: (item: Item) => ComponentChild;
150
+ formatSelectedItem?: (item: Item) => ComponentChild;
151
+ inputClassName?: string;
152
+ }) {
153
+ const [selectedItems, setSelectedItems] = useState<Item[]>(() => value);
154
+ const [itemsFilter, setItemsFilter] = useState('');
155
+
156
+ useEffect(() => {
157
+ setSelectedItems(value);
158
+ }, [value]);
159
+
160
+ const availableItems = useMemo(() => {
161
+ return allItems.filter((item) => {
162
+ const notAlreadySelected = !selectedItems.find(
163
+ (selectedItem) => itemToString(selectedItem) === itemToString(item),
164
+ );
165
+ const matchesFilter = filterItemsByInputValue(item, itemsFilter);
166
+ return notAlreadySelected && matchesFilter;
167
+ });
168
+ }, [allItems, selectedItems, filterItemsByInputValue, itemsFilter, itemToString]);
169
+
170
+ const divRef = useRef<HTMLDivElement>(null);
171
+
172
+ const dispatchEvent = (items: Item[]) => {
173
+ setSelectedItems(items);
174
+ divRef.current?.dispatchEvent(createEvent(items));
175
+ };
176
+
177
+ const environment = useShadowEnvironment(divRef);
178
+
179
+ const { getDropdownProps, removeSelectedItem } = useMultipleSelection({
180
+ selectedItems,
181
+ onStateChange({ selectedItems: newSelectedItems, type }) {
182
+ switch (type) {
183
+ case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
184
+ dispatchEvent(newSelectedItems ?? []);
185
+ break;
186
+ default:
187
+ break;
188
+ }
189
+ },
190
+ environment,
191
+ });
192
+
193
+ const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, highlightedIndex, getItemProps, closeMenu } =
194
+ useCombobox({
195
+ items: availableItems,
196
+ itemToString(item) {
197
+ return itemToString(item);
198
+ },
199
+ inputValue: itemsFilter,
200
+ onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) {
201
+ switch (type) {
202
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
203
+ case useCombobox.stateChangeTypes.ItemClick:
204
+ if (newSelectedItem) {
205
+ dispatchEvent([...selectedItems, newSelectedItem]);
206
+ setItemsFilter('');
207
+ }
208
+ break;
209
+ case useCombobox.stateChangeTypes.InputChange:
210
+ setItemsFilter(newInputValue?.trim() ?? '');
211
+ break;
212
+ default:
213
+ break;
214
+ }
215
+ },
216
+ stateReducer(state, actionAndChanges) {
217
+ const { changes, type } = actionAndChanges;
218
+ switch (type) {
219
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
220
+ case useCombobox.stateChangeTypes.ItemClick:
221
+ return {
222
+ ...changes,
223
+ isOpen: true,
224
+ highlightedIndex: state.highlightedIndex,
225
+ inputValue: '',
226
+ };
227
+ default:
228
+ return changes;
229
+ }
230
+ },
231
+ environment,
232
+ });
233
+
234
+ const clearAll = () => {
235
+ dispatchEvent([]);
236
+ setItemsFilter('');
237
+ };
238
+
239
+ const buttonRef = useRef(null);
240
+
241
+ return (
242
+ <div ref={divRef} className={'relative w-full'}>
243
+ <div className='w-full flex flex-col gap-1'>
244
+ <div
245
+ className={`flex gap-1 flex-wrap p-1.5 input min-w-24 h-fit w-full ${inputClassName}`}
246
+ onBlur={(event) => {
247
+ if (event.relatedTarget != buttonRef.current) {
248
+ closeMenu();
249
+ }
250
+ }}
251
+ >
252
+ {selectedItems.map((selectedItem, index) => (
253
+ <span
254
+ key={`${itemToString(selectedItem)}-${index}`}
255
+ className='inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-black rounded'
153
256
  >
154
- {formatItemInList(item)}
155
- </li>
156
- ))
157
- ) : (
158
- <li className='py-2 px-3 shadow-xs'>No elements to select.</li>
159
- )}
160
- </ul>
257
+ {formatSelectedItem ? formatSelectedItem(selectedItem) : itemToString(selectedItem)}
258
+ <button
259
+ aria-label={`remove ${itemToString(selectedItem)}`}
260
+ className='cursor-pointer hover:text-red-600'
261
+ type='button'
262
+ onClick={() => removeSelectedItem(selectedItem)}
263
+ tabIndex={-1}
264
+ >
265
+ ×
266
+ </button>
267
+ </span>
268
+ ))}
269
+ <div className='flex gap-0.5 grow min-w-32'>
270
+ <input
271
+ placeholder={placeholderText}
272
+ className='w-full px-1 py-0.5 focus:outline-none min-w-24'
273
+ {...getInputProps(getDropdownProps({ preventKeyAction: isOpen }))}
274
+ />
275
+ <ClearButton onClick={clearAll} isHidden={selectedItems.length === 0} />
276
+ <ToggleButton
277
+ isOpen={isOpen}
278
+ buttonRef={buttonRef}
279
+ getToggleButtonProps={getToggleButtonProps}
280
+ />
281
+ </div>
282
+ </div>
283
+ </div>
284
+ <DropdownMenu
285
+ isOpen={isOpen}
286
+ getMenuProps={getMenuProps}
287
+ items={availableItems}
288
+ highlightedIndex={highlightedIndex}
289
+ getItemProps={getItemProps}
290
+ formatItemInList={formatItemInList}
291
+ itemToString={itemToString}
292
+ selectedItem={selectedItems}
293
+ emptyMessage={selectedItems.length > 0 ? 'No more elements to select.' : 'No elements to select.'}
294
+ />
161
295
  </div>
162
296
  );
163
297
  }
298
+
299
+ function useShadowEnvironment(divRef: React.RefObject<HTMLDivElement>) {
300
+ const shadowRoot = divRef.current?.shadowRoot ?? undefined;
301
+
302
+ return shadowRoot !== undefined
303
+ ? {
304
+ addEventListener: window.addEventListener.bind(window),
305
+ removeEventListener: window.removeEventListener.bind(window),
306
+ document: shadowRoot.ownerDocument,
307
+ Node: window.Node,
308
+ }
309
+ : undefined;
310
+ }
311
+
312
+ function ToggleButton({
313
+ isOpen,
314
+ buttonRef,
315
+ getToggleButtonProps,
316
+ onClick,
317
+ }: {
318
+ isOpen: boolean;
319
+ buttonRef?: React.Ref<HTMLButtonElement>;
320
+ getToggleButtonProps?: () => Record<string, unknown>;
321
+ onClick?: () => void;
322
+ }) {
323
+ const props = getToggleButtonProps ? getToggleButtonProps() : { onClick };
324
+ return (
325
+ <button aria-label='toggle menu' className='px-2' type='button' {...props} ref={buttonRef}>
326
+ {isOpen ? <>↑</> : <>↓</>}
327
+ </button>
328
+ );
329
+ }
330
+
331
+ function ClearButton({ onClick, isHidden }: { onClick: () => void; isHidden: boolean }) {
332
+ return (
333
+ <button
334
+ aria-label='clear selection'
335
+ className={`px-2 ${isHidden ? 'hidden' : ''}`}
336
+ type='button'
337
+ onClick={onClick}
338
+ tabIndex={-1}
339
+ >
340
+ <DeleteIcon />
341
+ </button>
342
+ );
343
+ }
344
+
345
+ function DropdownMenu<Item>({
346
+ isOpen,
347
+ getMenuProps,
348
+ items,
349
+ highlightedIndex,
350
+ getItemProps,
351
+ formatItemInList,
352
+ itemToString,
353
+ selectedItem,
354
+ emptyMessage,
355
+ }: {
356
+ isOpen: boolean;
357
+ getMenuProps: () => Record<string, unknown>;
358
+ items: Item[];
359
+ highlightedIndex: number;
360
+ getItemProps: (options: { item: Item; index: number }) => Record<string, unknown>;
361
+ formatItemInList: (item: Item) => ComponentChild;
362
+ itemToString: (item: Item) => string;
363
+ selectedItem?: Item | Item[] | null;
364
+ emptyMessage: string;
365
+ }) {
366
+ const isItemSelected = (item: Item) => {
367
+ if (Array.isArray(selectedItem)) {
368
+ return selectedItem.some((selected) => itemToString(selected) === itemToString(item));
369
+ }
370
+ return selectedItem !== null && selectedItem !== undefined && itemToString(selectedItem) === itemToString(item);
371
+ };
372
+
373
+ return (
374
+ <ul
375
+ className={`absolute bg-white mt-1 shadow-md max-h-80 overflow-scroll z-10 w-full min-w-32 ${isOpen ? '' : 'hidden'}`}
376
+ {...getMenuProps()}
377
+ >
378
+ {items.length > 0 ? (
379
+ items.map((item, index) => (
380
+ <li
381
+ className={`${highlightedIndex === index ? 'bg-blue-300' : ''} ${isItemSelected(item) ? 'font-bold' : ''} py-2 px-3 shadow-xs cursor-pointer`}
382
+ key={itemToString(item)}
383
+ {...getItemProps({ item, index })}
384
+ >
385
+ {formatItemInList(item)}
386
+ </li>
387
+ ))
388
+ ) : (
389
+ <li className='py-2 px-3 shadow-xs'>{emptyMessage}</li>
390
+ )}
391
+ </ul>
392
+ );
393
+ }
@@ -171,7 +171,9 @@ export const SetsValueOnBlur: StoryObj<DateRangeFilterProps> = {
171
171
  },
172
172
  }),
173
173
  );
174
+ });
174
175
 
176
+ await waitFor(async () => {
175
177
  await expect(optionChangedListenerMock).toHaveBeenCalledWith(
176
178
  expect.objectContaining({
177
179
  detail: {
@@ -1,6 +1,7 @@
1
1
  import { gsEventNames } from '../../utils/gsEventNames';
2
2
 
3
3
  type LapisLineageFilter = Record<string, string | undefined>;
4
+ type LapisLineageMultiFilter = Record<string, string[] | undefined>;
4
5
 
5
6
  export class LineageFilterChangedEvent extends CustomEvent<LapisLineageFilter> {
6
7
  constructor(detail: LapisLineageFilter) {
@@ -11,3 +12,13 @@ export class LineageFilterChangedEvent extends CustomEvent<LapisLineageFilter> {
11
12
  });
12
13
  }
13
14
  }
15
+
16
+ export class LineageMultiFilterChangedEvent extends CustomEvent<LapisLineageMultiFilter> {
17
+ constructor(detail: LapisLineageMultiFilter) {
18
+ super(gsEventNames.lineageFilterMultiChanged, {
19
+ detail,
20
+ bubbles: true,
21
+ composed: true,
22
+ });
23
+ }
24
+ }
@@ -2,6 +2,8 @@ import { fetchLineageDefinition } from '../../lapisApi/lapisApi';
2
2
  import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
3
3
  import type { LapisFilter } from '../../types';
4
4
 
5
+ export type LineageItem = { lineage: string; count: number };
6
+
5
7
  /**
6
8
  * Generates the autocomplete list for lineage search. It includes lineages with wild cards
7
9
  * (i.e. "BA.3.2.1" and "BA.3.2.1*") as well as all prefixes of lineages with an asterisk ("BA.3.2*").
@@ -56,8 +58,6 @@ export async function fetchLineageAutocompleteList({
56
58
  });
57
59
  }
58
60
 
59
- export type LineageItem = { lineage: string; count: number };
60
-
61
61
  async function getCountsByLineage({
62
62
  lapisUrl,
63
63
  lapisField,