@genspectrum/dashboard-components 1.9.2 → 1.10.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
@@ -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";
@@ -973,7 +974,7 @@ declare global {
973
974
 
974
975
  declare global {
975
976
  interface HTMLElementTagNameMap {
976
- 'gs-mutations': MutationsComponent;
977
+ 'gs-prevalence-over-time': PrevalenceOverTimeComponent;
977
978
  }
978
979
  }
979
980
 
@@ -981,7 +982,7 @@ declare global {
981
982
  declare global {
982
983
  namespace JSX {
983
984
  interface IntrinsicElements {
984
- 'gs-mutations': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
985
+ 'gs-prevalence-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
985
986
  }
986
987
  }
987
988
  }
@@ -989,7 +990,7 @@ declare global {
989
990
 
990
991
  declare global {
991
992
  interface HTMLElementTagNameMap {
992
- 'gs-prevalence-over-time': PrevalenceOverTimeComponent;
993
+ 'gs-mutations': MutationsComponent;
993
994
  }
994
995
  }
995
996
 
@@ -997,7 +998,7 @@ declare global {
997
998
  declare global {
998
999
  namespace JSX {
999
1000
  interface IntrinsicElements {
1000
- 'gs-prevalence-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1001
+ 'gs-mutations': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1001
1002
  }
1002
1003
  }
1003
1004
  }
@@ -1099,22 +1100,6 @@ declare global {
1099
1100
  }
1100
1101
 
1101
1102
 
1102
- declare global {
1103
- interface HTMLElementTagNameMap {
1104
- 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1105
- }
1106
- }
1107
-
1108
-
1109
- declare global {
1110
- namespace JSX {
1111
- interface IntrinsicElements {
1112
- 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1113
- }
1114
- }
1115
- }
1116
-
1117
-
1118
1103
  declare global {
1119
1104
  interface HTMLElementTagNameMap {
1120
1105
  'gs-date-range-filter': DateRangeFilterComponent;
@@ -1198,6 +1183,7 @@ declare global {
1198
1183
  }
1199
1184
  interface HTMLElementEventMap {
1200
1185
  [gsEventNames.lineageFilterChanged]: LineageFilterChangedEvent;
1186
+ [gsEventNames.lineageFilterMultiChanged]: LineageMultiFilterChangedEvent;
1201
1187
  }
1202
1188
  }
1203
1189
 
@@ -1231,6 +1217,22 @@ declare global {
1231
1217
  }
1232
1218
 
1233
1219
 
1220
+ declare global {
1221
+ interface HTMLElementTagNameMap {
1222
+ 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1223
+ }
1224
+ }
1225
+
1226
+
1227
+ declare global {
1228
+ namespace JSX {
1229
+ interface IntrinsicElements {
1230
+ 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+
1234
1236
  declare module 'chart.js' {
1235
1237
  interface CartesianScaleTypeRegistry {
1236
1238
  logit: {
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.0",
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
+ }
@@ -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,