@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.
@@ -17,7 +17,7 @@ const meta: Meta = {
17
17
  component: LineageFilter,
18
18
  parameters: {
19
19
  actions: {
20
- handles: [gsEventNames.lineageFilterChanged, ...previewHandles],
20
+ handles: [gsEventNames.lineageFilterChanged, gsEventNames.lineageFilterMultiChanged, ...previewHandles],
21
21
  },
22
22
  fetchMock: {
23
23
  mocks: [
@@ -61,7 +61,7 @@ const meta: Meta = {
61
61
  },
62
62
  value: {
63
63
  control: {
64
- type: 'text',
64
+ type: 'object',
65
65
  },
66
66
  },
67
67
  width: {
@@ -79,6 +79,11 @@ const meta: Meta = {
79
79
  type: 'boolean',
80
80
  },
81
81
  },
82
+ multiSelect: {
83
+ control: {
84
+ type: 'boolean',
85
+ },
86
+ },
82
87
  },
83
88
 
84
89
  args: {
@@ -90,6 +95,7 @@ const meta: Meta = {
90
95
  value: 'A.1',
91
96
  width: '100%',
92
97
  hideCounts: false,
98
+ multiSelect: false,
93
99
  },
94
100
  };
95
101
 
@@ -169,6 +175,40 @@ export const WithNoLapisField: StoryObj<LineageFilterProps> = {
169
175
  },
170
176
  };
171
177
 
178
+ export const WithStringValueInMultiSelectMode: StoryObj<LineageFilterProps> = {
179
+ ...Default,
180
+ args: {
181
+ ...Default.args,
182
+ multiSelect: true,
183
+ value: 'A.1',
184
+ },
185
+ play: async ({ canvasElement, step }) => {
186
+ await step('expect error message', async () => {
187
+ await expectInvalidAttributesErrorMessage(
188
+ canvasElement,
189
+ 'When multiSelect is true, value must be an array of strings',
190
+ );
191
+ });
192
+ },
193
+ };
194
+
195
+ export const WithArrayValueInSingleSelectMode: StoryObj<LineageFilterProps> = {
196
+ ...Default,
197
+ args: {
198
+ ...Default.args,
199
+ multiSelect: false,
200
+ value: ['A.1', 'B.1'],
201
+ },
202
+ play: async ({ canvasElement, step }) => {
203
+ await step('expect error message', async () => {
204
+ await expectInvalidAttributesErrorMessage(
205
+ canvasElement,
206
+ 'When multiSelect is false or undefined, value must be a string',
207
+ );
208
+ });
209
+ },
210
+ };
211
+
172
212
  export const WithHideCountsTrue: StoryObj<LineageFilterProps> = {
173
213
  ...Default,
174
214
  args: {
@@ -243,6 +283,145 @@ export const EnterAndClearMultipleTimes: StoryObj<LineageFilterProps> = {
243
283
  },
244
284
  };
245
285
 
286
+ export const MultiSelectDefault: StoryObj<LineageFilterProps> = {
287
+ render: (args) => (
288
+ <LapisUrlContextProvider value={LAPIS_URL}>
289
+ <LineageFilter {...args} />
290
+ </LapisUrlContextProvider>
291
+ ),
292
+ args: {
293
+ ...Default.args,
294
+ multiSelect: true,
295
+ value: ['A.1', 'B.1'],
296
+ placeholderText: 'Select lineages',
297
+ },
298
+ play: async ({ canvasElement, step }) => {
299
+ const canvas = within(canvasElement);
300
+ const lineageChangedListenerMock = fn();
301
+
302
+ await step('Setup event listener mock', () => {
303
+ canvasElement.addEventListener(gsEventNames.lineageFilterMultiChanged, lineageChangedListenerMock);
304
+ });
305
+
306
+ await step('multi-select filter is rendered with initial values', async () => {
307
+ await waitFor(async () => {
308
+ await expect(canvas.getByText('A.1')).toBeVisible();
309
+ await expect(canvas.getByText('B.1')).toBeVisible();
310
+ });
311
+ });
312
+
313
+ await step('add another lineage', async () => {
314
+ const input = await canvas.findByPlaceholderText('Select lineages');
315
+ await userEvent.type(input, 'C.1');
316
+ await userEvent.click(canvas.getByRole('option', { name: 'C.1(23)' }));
317
+
318
+ await waitFor(() => {
319
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
320
+ pangoLineage: ['A.1', 'B.1', 'C.1'],
321
+ });
322
+ });
323
+ });
324
+
325
+ await step('verify all three lineages are displayed', async () => {
326
+ await expect(canvas.getByText('A.1')).toBeVisible();
327
+ await expect(canvas.getByText('B.1')).toBeVisible();
328
+ await expect(canvas.getByText('C.1')).toBeVisible();
329
+ });
330
+ },
331
+ };
332
+
333
+ export const MultiSelectRemoveItem: StoryObj<LineageFilterProps> = {
334
+ render: (args) => (
335
+ <LapisUrlContextProvider value={LAPIS_URL}>
336
+ <LineageFilter {...args} />
337
+ </LapisUrlContextProvider>
338
+ ),
339
+ args: {
340
+ ...Default.args,
341
+ multiSelect: true,
342
+ value: ['A.1', 'B.1', 'C.1'],
343
+ placeholderText: 'Select lineages',
344
+ },
345
+ play: async ({ canvasElement, step }) => {
346
+ const canvas = within(canvasElement);
347
+ const lineageChangedListenerMock = fn();
348
+
349
+ await step('Setup event listener mock', () => {
350
+ canvasElement.addEventListener(gsEventNames.lineageFilterMultiChanged, lineageChangedListenerMock);
351
+ });
352
+
353
+ await step('multi-select filter is rendered with three values', async () => {
354
+ await waitFor(async () => {
355
+ await expect(canvas.getByText('A.1')).toBeVisible();
356
+ await expect(canvas.getByText('B.1')).toBeVisible();
357
+ await expect(canvas.getByText('C.1')).toBeVisible();
358
+ });
359
+ });
360
+
361
+ await step('remove B.1 lineage', async () => {
362
+ const removeButton = canvas.getByLabelText('remove B.1');
363
+ await userEvent.click(removeButton);
364
+
365
+ await waitFor(() => {
366
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
367
+ pangoLineage: ['A.1', 'C.1'],
368
+ });
369
+ });
370
+ });
371
+
372
+ await step('verify B.1 is removed', async () => {
373
+ await expect(canvas.queryByText('B.1')).not.toBeVisible();
374
+ await expect(canvas.getByText('A.1')).toBeVisible();
375
+ await expect(canvas.getByText('C.1')).toBeVisible();
376
+ });
377
+ },
378
+ };
379
+
380
+ export const MultiSelectClearAll: StoryObj<LineageFilterProps> = {
381
+ render: (args) => (
382
+ <LapisUrlContextProvider value={LAPIS_URL}>
383
+ <LineageFilter {...args} />
384
+ </LapisUrlContextProvider>
385
+ ),
386
+ args: {
387
+ ...Default.args,
388
+ multiSelect: true,
389
+ value: ['A.1', 'B.1'],
390
+ placeholderText: 'Select lineages',
391
+ },
392
+ play: async ({ canvasElement, step }) => {
393
+ const canvas = within(canvasElement);
394
+ const lineageChangedListenerMock = fn();
395
+
396
+ await step('Setup event listener mock', () => {
397
+ canvasElement.addEventListener(gsEventNames.lineageFilterMultiChanged, lineageChangedListenerMock);
398
+ });
399
+
400
+ await step('multi-select filter is rendered with values', async () => {
401
+ await waitFor(async () => {
402
+ await expect(canvas.getByText('A.1')).toBeVisible();
403
+ await expect(canvas.getByText('B.1')).toBeVisible();
404
+ });
405
+ });
406
+
407
+ await step('clear all selections', async () => {
408
+ const clearButton = canvas.getByLabelText('clear selection');
409
+ await userEvent.click(clearButton);
410
+
411
+ await waitFor(() => {
412
+ return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
413
+ pangoLineage: undefined,
414
+ });
415
+ });
416
+ });
417
+
418
+ await step('verify all chips are removed', async () => {
419
+ await expect(canvas.queryByText('A.1')).not.toBeVisible();
420
+ await expect(canvas.queryByText('B.1')).not.toBeVisible();
421
+ });
422
+ },
423
+ };
424
+
246
425
  async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRenderer, unknown>) {
247
426
  const canvas = within(canvasElement);
248
427
 
@@ -3,10 +3,10 @@ import { useMemo } from 'preact/hooks';
3
3
  import z from 'zod';
4
4
 
5
5
  import { useLapisUrl } from '../LapisUrlContext';
6
- import { LineageFilterChangedEvent } from './LineageFilterChangedEvent';
7
- import { fetchLineageAutocompleteList, type LineageItem } from './fetchLineageAutocompleteList';
6
+ import { LineageFilterChangedEvent, LineageMultiFilterChangedEvent } from './LineageFilterChangedEvent';
7
+ import { type LineageItem, fetchLineageAutocompleteList } from './fetchLineageAutocompleteList';
8
8
  import { lapisFilterSchema } from '../../types';
9
- import { DownshiftCombobox } from '../components/downshift-combobox';
9
+ import { DownshiftCombobox, DownshiftMultiCombobox } from '../components/downshift-combobox';
10
10
  import { ErrorBoundary } from '../components/error-boundary';
11
11
  import { LoadingDisplay } from '../components/loading-display';
12
12
  import { ResizeContainer } from '../components/resize-container';
@@ -15,15 +15,36 @@ import { useQuery } from '../useQuery';
15
15
  const lineageSelectorPropsSchema = z.object({
16
16
  lapisField: z.string().min(1),
17
17
  placeholderText: z.string().optional(),
18
- value: z.string(),
18
+ value: z.union([z.string(), z.array(z.string())]),
19
19
  hideCounts: z.boolean().optional(),
20
+ multiSelect: z.boolean().optional(),
20
21
  });
22
+
21
23
  const lineageFilterInnerPropsSchema = lineageSelectorPropsSchema.extend({
22
24
  lapisFilter: lapisFilterSchema,
23
25
  });
24
- const lineageFilterPropsSchema = lineageFilterInnerPropsSchema.extend({
25
- width: z.string(),
26
- });
26
+
27
+ const lineageFilterPropsSchema = lineageFilterInnerPropsSchema
28
+ .extend({
29
+ width: z.string(),
30
+ })
31
+ .refine(
32
+ (data) => {
33
+ if (data.multiSelect && typeof data.value === 'string') {
34
+ return false;
35
+ }
36
+ if (!data.multiSelect && Array.isArray(data.value)) {
37
+ return false;
38
+ }
39
+ return true;
40
+ },
41
+ (data) => ({
42
+ message: data.multiSelect
43
+ ? 'When multiSelect is true, value must be an array of strings'
44
+ : 'When multiSelect is false or undefined, value must be a string',
45
+ path: ['value'],
46
+ }),
47
+ );
27
48
 
28
49
  export type LineageFilterInnerProps = z.infer<typeof lineageFilterInnerPropsSchema>;
29
50
  export type LineageFilterProps = z.infer<typeof lineageFilterPropsSchema>;
@@ -48,6 +69,7 @@ const LineageFilterInner: FunctionComponent<LineageFilterInnerProps> = ({
48
69
  value,
49
70
  lapisFilter,
50
71
  hideCounts,
72
+ multiSelect = false,
51
73
  }) => {
52
74
  const lapisUrl = useLapisUrl();
53
75
 
@@ -71,6 +93,7 @@ const LineageFilterInner: FunctionComponent<LineageFilterInnerProps> = ({
71
93
  placeholderText={placeholderText}
72
94
  data={data}
73
95
  hideCounts={hideCounts}
96
+ multiSelect={multiSelect}
74
97
  />
75
98
  );
76
99
  };
@@ -81,13 +104,46 @@ const LineageSelector = ({
81
104
  placeholderText,
82
105
  data,
83
106
  hideCounts = false,
107
+ multiSelect = false,
84
108
  }: LineageSelectorProps & {
85
109
  data: LineageItem[];
86
110
  }) => {
111
+ const formatItemInList = (item: LineageItem) => (
112
+ <p>
113
+ <span>{item.lineage}</span>
114
+ {!hideCounts && <span className='ml-2 text-gray-500'>({item.count.toLocaleString('en-US')})</span>}
115
+ </p>
116
+ );
117
+
118
+ const selectedItems = useMemo(() => {
119
+ const valueArray = Array.isArray(value) ? value : [];
120
+ return valueArray
121
+ .map((lineageValue) => data.find((item) => item.lineage === lineageValue))
122
+ .filter((item): item is LineageItem => item !== undefined);
123
+ }, [data, value]);
124
+
87
125
  const selectedItem = useMemo(() => {
88
- return data.find((item) => item.lineage === value) ?? null;
126
+ const valueString = typeof value === 'string' ? value : '';
127
+ return data.find((item) => item.lineage === valueString) ?? null;
89
128
  }, [data, value]);
90
129
 
130
+ if (multiSelect) {
131
+ return (
132
+ <DownshiftMultiCombobox
133
+ allItems={data}
134
+ value={selectedItems}
135
+ filterItemsByInputValue={filterByInputValue}
136
+ createEvent={(items) => {
137
+ const lineages = items.length > 0 ? items.map((item) => item.lineage) : undefined;
138
+ return new LineageMultiFilterChangedEvent({ [lapisField]: lineages });
139
+ }}
140
+ itemToString={(item) => item?.lineage ?? ''}
141
+ placeholderText={placeholderText ?? 'Select lineages'}
142
+ formatItemInList={formatItemInList}
143
+ formatSelectedItem={(item: LineageItem) => <span>{item.lineage}</span>}
144
+ />
145
+ );
146
+ }
91
147
  return (
92
148
  <DownshiftCombobox
93
149
  allItems={data}
@@ -96,12 +152,7 @@ const LineageSelector = ({
96
152
  createEvent={(item) => new LineageFilterChangedEvent({ [lapisField]: item?.lineage ?? undefined })}
97
153
  itemToString={(item) => item?.lineage ?? ''}
98
154
  placeholderText={placeholderText}
99
- formatItemInList={(item: LineageItem) => (
100
- <p>
101
- <span>{item.lineage}</span>
102
- {!hideCounts && <span className='ml-2 text-gray-500'>({item.count.toLocaleString('en-US')})</span>}
103
- </p>
104
- )}
155
+ formatItemInList={formatItemInList}
105
156
  />
106
157
  );
107
158
  };
@@ -5,6 +5,7 @@ export const gsEventNames = {
5
5
  dateRangeOptionChanged: 'gs-date-range-option-changed',
6
6
  mutationFilterChanged: 'gs-mutation-filter-changed',
7
7
  lineageFilterChanged: 'gs-lineage-filter-changed',
8
+ lineageFilterMultiChanged: 'gs-lineage-filter-multi-changed',
8
9
  locationChanged: 'gs-location-changed',
9
10
  textFilterChanged: 'gs-text-filter-changed',
10
11
  numberRangeFilterChanged: 'gs-number-range-filter-changed',
@@ -0,0 +1,30 @@
1
+ import { describe, expectTypeOf, test } from 'vitest';
2
+
3
+ import { LineageFilterComponent } from './gs-lineage-filter';
4
+ import { type LineageFilterProps } from '../../preact/lineageFilter/lineage-filter';
5
+
6
+ describe('gs-lineage-filter types', () => {
7
+ test('should match', () => {
8
+ expectTypeOf(LineageFilterComponent.prototype)
9
+ .toHaveProperty('value')
10
+ .toEqualTypeOf<LineageFilterProps['value']>();
11
+ expectTypeOf(LineageFilterComponent.prototype)
12
+ .toHaveProperty('lapisField')
13
+ .toEqualTypeOf<LineageFilterProps['lapisField']>();
14
+ expectTypeOf(LineageFilterComponent.prototype)
15
+ .toHaveProperty('lapisFilter')
16
+ .toEqualTypeOf<LineageFilterProps['lapisFilter']>();
17
+ expectTypeOf(LineageFilterComponent.prototype)
18
+ .toHaveProperty('placeholderText')
19
+ .toEqualTypeOf<LineageFilterProps['placeholderText']>();
20
+ expectTypeOf(LineageFilterComponent.prototype)
21
+ .toHaveProperty('width')
22
+ .toEqualTypeOf<LineageFilterProps['width']>();
23
+ expectTypeOf(LineageFilterComponent.prototype)
24
+ .toHaveProperty('multiSelect')
25
+ .toEqualTypeOf<LineageFilterProps['multiSelect']>();
26
+ expectTypeOf(LineageFilterComponent.prototype)
27
+ .toHaveProperty('hideCounts')
28
+ .toEqualTypeOf<LineageFilterProps['hideCounts']>();
29
+ });
30
+ });
@@ -28,7 +28,7 @@ const meta: Meta<Required<LineageFilterProps>> = {
28
28
  component: 'gs-lineage-filter',
29
29
  parameters: withComponentDocs({
30
30
  actions: {
31
- handles: [gsEventNames.lineageFilterChanged, ...previewHandles],
31
+ handles: [gsEventNames.lineageFilterChanged, gsEventNames.lineageFilterMultiChanged, ...previewHandles],
32
32
  },
33
33
  fetchMock: {
34
34
  mocks: [
@@ -79,7 +79,7 @@ const meta: Meta<Required<LineageFilterProps>> = {
79
79
  },
80
80
  value: {
81
81
  control: {
82
- type: 'text',
82
+ type: 'object',
83
83
  },
84
84
  },
85
85
  width: {
@@ -97,6 +97,11 @@ const meta: Meta<Required<LineageFilterProps>> = {
97
97
  type: 'boolean',
98
98
  },
99
99
  },
100
+ multiSelect: {
101
+ control: {
102
+ type: 'boolean',
103
+ },
104
+ },
100
105
  },
101
106
  };
102
107
 
@@ -113,6 +118,7 @@ const Template: StoryObj<Required<LineageFilterProps>> = {
113
118
  .hideCounts=${args.hideCounts}
114
119
  .value=${args.value}
115
120
  .width=${args.width}
121
+ .multiSelect=${args.multiSelect}
116
122
  ></gs-lineage-filter>
117
123
  </div>
118
124
  </gs-app>`;
@@ -126,6 +132,7 @@ const Template: StoryObj<Required<LineageFilterProps>> = {
126
132
  value: 'B.1.1.7',
127
133
  width: '100%',
128
134
  hideCounts: false,
135
+ multiSelect: false,
129
136
  },
130
137
  };
131
138
 
@@ -148,6 +155,54 @@ export const LineageFilter: StoryObj<Required<LineageFilterProps>> = {
148
155
  },
149
156
  };
150
157
 
158
+ export const LineageFilterStringValue: StoryObj<Required<LineageFilterProps>> = {
159
+ render: (args) => {
160
+ return html` <gs-app lapis="${LAPIS_URL}">
161
+ <div class="max-w-(--breakpoint-lg)">
162
+ <gs-lineage-filter
163
+ lapisField="pangoLineage"
164
+ placeholderText="Enter a lineage"
165
+ value="B.1.1.7"
166
+ .multiSelect=${args.multiSelect}
167
+ ></gs-lineage-filter>
168
+ </div>
169
+ </gs-app>`;
170
+ },
171
+ args: {
172
+ multiSelect: false,
173
+ },
174
+ play: async ({ canvasElement }) => {
175
+ const canvas = await withinShadowRoot(canvasElement, 'gs-lineage-filter');
176
+ await waitFor(() => {
177
+ return expect(canvas.getByPlaceholderText('Enter a lineage')).toBeVisible();
178
+ });
179
+ },
180
+ };
181
+
182
+ export const LineageFilterArrayValue: StoryObj<Required<LineageFilterProps>> = {
183
+ render: (args) => {
184
+ return html` <gs-app lapis="${LAPIS_URL}">
185
+ <div class="max-w-(--breakpoint-lg)">
186
+ <gs-lineage-filter
187
+ lapisField="pangoLineage"
188
+ placeholderText="Enter a lineage"
189
+ value='["B.1.1.7", "B.1.1.10"]'
190
+ .multiSelect=${args.multiSelect}
191
+ ></gs-lineage-filter>
192
+ </div>
193
+ </gs-app>`;
194
+ },
195
+ args: {
196
+ multiSelect: true,
197
+ },
198
+ play: async ({ canvasElement }) => {
199
+ const canvas = await withinShadowRoot(canvasElement, 'gs-lineage-filter');
200
+ await waitFor(() => {
201
+ return expect(canvas.getByPlaceholderText('Enter a lineage')).toBeVisible();
202
+ });
203
+ },
204
+ };
205
+
151
206
  export const DelayToShowLoadingState: StoryObj<Required<LineageFilterProps>> = {
152
207
  ...Template,
153
208
  parameters: {
@@ -243,3 +298,19 @@ export const FiresEvent: StoryObj<Required<LineageFilterProps>> = {
243
298
  value: '',
244
299
  },
245
300
  };
301
+
302
+ export const MultiSelectMode: StoryObj<Required<LineageFilterProps>> = {
303
+ ...Template,
304
+ args: {
305
+ ...Template.args,
306
+ multiSelect: true,
307
+ value: ['B.1.1.7', 'BA.5'],
308
+ placeholderText: 'Select lineages',
309
+ },
310
+ play: async ({ canvasElement }) => {
311
+ const canvas = await withinShadowRoot(canvasElement, 'gs-lineage-filter');
312
+ await waitFor(() => {
313
+ return expect(canvas.getByPlaceholderText('Select lineages')).toBeVisible();
314
+ });
315
+ },
316
+ };
@@ -1,14 +1,15 @@
1
1
  import { customElement, property } from 'lit/decorators.js';
2
2
  import type { DetailedHTMLProps, HTMLAttributes } from 'react';
3
3
 
4
- import { type LineageFilterChangedEvent } from '../../preact/lineageFilter/LineageFilterChangedEvent';
5
- import { LineageFilter, type LineageFilterProps } from '../../preact/lineageFilter/lineage-filter';
4
+ import {
5
+ type LineageFilterChangedEvent,
6
+ type LineageMultiFilterChangedEvent,
7
+ } from '../../preact/lineageFilter/LineageFilterChangedEvent';
8
+ import { LineageFilter } from '../../preact/lineageFilter/lineage-filter';
6
9
  import { type gsEventNames } from '../../utils/gsEventNames';
7
- import type { Equals, Expect } from '../../utils/typeAssertions';
8
10
  import { PreactLitAdapter } from '../PreactLitAdapter';
9
11
 
10
12
  /**
11
- *
12
13
  * ## Context
13
14
  *
14
15
  * This component provides a text input field to filter by lineages.
@@ -19,23 +20,37 @@ import { PreactLitAdapter } from '../PreactLitAdapter';
19
20
  * and provides an autocomplete list with the available values of the lineage and sublineage queries
20
21
  * (a `*` appended to the lineage value).
21
22
  *
23
+ * When `multiSelect` is true, it allows selecting multiple lineages and displays them as removable chips.
24
+ *
22
25
  * @fires {CustomEvent<Record<string, string | undefined>>} gs-lineage-filter-changed
23
- * Fired when the input field is changed.
24
- * The `details` of this event contain an object with the `lapisField` as key and the input value as value.
26
+ * Fired when the selection changes in single-select mode.
27
+ * The `details` of this event contain an object with the `lapisField` as key and the selected value as value.
25
28
  * Example:
26
29
  * ```
27
30
  * {
28
31
  * "pangoLineage": "B.1.1.7"
29
32
  * }
30
- * ```
33
+ * ```
34
+ *
35
+ * @fires {CustomEvent<Record<string, string[] | undefined>>} gs-lineage-filter-multi-changed
36
+ * Fired when the selection changes in multi-select mode.
37
+ * The `details` of this event contain an object with the `lapisField` as key and an array of selected values.
38
+ * Example:
39
+ * ```
40
+ * {
41
+ * "pangoLineage": ["B.1.1.7", "BA.5"]
42
+ * }
43
+ * ```
31
44
  */
32
45
  @customElement('gs-lineage-filter')
33
46
  export class LineageFilterComponent extends PreactLitAdapter {
34
47
  /**
35
48
  * The initial value to use for this lineage filter.
49
+ * Can be a string for single select mode or an array of strings (or comma-separated string) for multi-select mode.
50
+ * Examples: "B.1.1.7" or ["B.1.1.7", "BA.5"] or "B.1.1.7,BA.5"
36
51
  */
37
52
  @property()
38
- value: string = '';
53
+ value: string | string[] = '';
39
54
 
40
55
  /**
41
56
  * Required.
@@ -46,6 +61,14 @@ export class LineageFilterComponent extends PreactLitAdapter {
46
61
  @property()
47
62
  lapisField = '';
48
63
 
64
+ /**
65
+ * Whether to enable multi-select mode.
66
+ * When true, allows selecting multiple lineages displayed as removable chips.
67
+ * Defaults to false.
68
+ */
69
+ @property({ type: Boolean })
70
+ multiSelect: boolean | undefined = false;
71
+
49
72
  /**
50
73
  * The filter that is used to fetch the available the autocomplete options.
51
74
  * If not set it fetches all available options.
@@ -80,6 +103,33 @@ export class LineageFilterComponent extends PreactLitAdapter {
80
103
  @property({ type: Boolean })
81
104
  hideCounts: boolean | undefined = false;
82
105
 
106
+ override updated(changedProps: Map<string, unknown>) {
107
+ if (changedProps.has('value') || changedProps.has('multiSelect')) {
108
+ if (this.multiSelect) {
109
+ if (typeof this.value === 'string') {
110
+ let parsed: unknown;
111
+ try {
112
+ parsed = JSON.parse(this.value);
113
+ } catch {
114
+ parsed = this.value.split(',').map((s) => s.trim());
115
+ }
116
+
117
+ // type guard
118
+ if (Array.isArray(parsed) && parsed.every((v) => typeof v === 'string')) {
119
+ this.value = parsed;
120
+ } else {
121
+ this.value = [];
122
+ }
123
+ }
124
+ } else {
125
+ // single select: ensure value is a string
126
+ if (Array.isArray(this.value)) {
127
+ this.value = this.value[0] ?? '';
128
+ }
129
+ }
130
+ }
131
+ }
132
+
83
133
  override render() {
84
134
  return (
85
135
  <LineageFilter
@@ -89,6 +139,7 @@ export class LineageFilterComponent extends PreactLitAdapter {
89
139
  value={this.value}
90
140
  width={this.width}
91
141
  hideCounts={this.hideCounts}
142
+ multiSelect={this.multiSelect}
92
143
  />
93
144
  );
94
145
  }
@@ -101,6 +152,7 @@ declare global {
101
152
 
102
153
  interface HTMLElementEventMap {
103
154
  [gsEventNames.lineageFilterChanged]: LineageFilterChangedEvent;
155
+ [gsEventNames.lineageFilterMultiChanged]: LineageMultiFilterChangedEvent;
104
156
  }
105
157
  }
106
158
 
@@ -112,17 +164,3 @@ declare global {
112
164
  }
113
165
  }
114
166
  }
115
-
116
- /* eslint-disable @typescript-eslint/no-unused-vars */
117
- type InitialValueMatches = Expect<Equals<typeof LineageFilterComponent.prototype.value, LineageFilterProps['value']>>;
118
- type LapisFieldMatches = Expect<
119
- Equals<typeof LineageFilterComponent.prototype.lapisField, LineageFilterProps['lapisField']>
120
- >;
121
- type LapisFilterMatches = Expect<
122
- Equals<typeof LineageFilterComponent.prototype.lapisFilter, LineageFilterProps['lapisFilter']>
123
- >;
124
- type PlaceholderTextMatches = Expect<
125
- Equals<typeof LineageFilterComponent.prototype.placeholderText, LineageFilterProps['placeholderText']>
126
- >;
127
- type WidthMatches = Expect<Equals<typeof LineageFilterComponent.prototype.width, LineageFilterProps['width']>>;
128
- /* eslint-enable @typescript-eslint/no-unused-vars */
@@ -162,13 +162,15 @@ 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).toHaveBeenCalledWith(
166
- expect.objectContaining({
167
- detail: {
168
- host: undefined,
169
- },
170
- }),
171
- );
165
+ await waitFor(async () => {
166
+ await expect(listenerMock).toHaveBeenCalledWith(
167
+ expect.objectContaining({
168
+ detail: {
169
+ host: undefined,
170
+ },
171
+ }),
172
+ );
173
+ });
172
174
  });
173
175
  },
174
176
  args: {