@genspectrum/dashboard-components 0.11.7 → 0.12.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.
Files changed (31) hide show
  1. package/custom-elements.json +16 -16
  2. package/dist/{dateRangeOption-Bh2p78z0.js → LocationChangedEvent-CORvQvXv.js} +11 -1
  3. package/dist/LocationChangedEvent-CORvQvXv.js.map +1 -0
  4. package/dist/components.d.ts +27 -33
  5. package/dist/components.js +4034 -655
  6. package/dist/components.js.map +1 -1
  7. package/dist/style.css +151 -4
  8. package/dist/util.d.ts +32 -26
  9. package/dist/util.js +2 -1
  10. package/package.json +2 -1
  11. package/src/preact/components/csv-download-button.tsx +2 -2
  12. package/src/preact/downshift_types.d.ts +3 -0
  13. package/src/preact/locationFilter/LocationChangedEvent.ts +11 -0
  14. package/src/preact/locationFilter/fetchAutocompletionList.spec.ts +5 -5
  15. package/src/preact/locationFilter/fetchAutocompletionList.ts +9 -2
  16. package/src/preact/locationFilter/location-filter.stories.tsx +94 -10
  17. package/src/preact/locationFilter/location-filter.tsx +183 -62
  18. package/src/preact/map/sequences-by-location-map.tsx +3 -3
  19. package/src/preact/mutationFilter/mutation-filter-info.tsx +73 -10
  20. package/src/preact/textInput/TextInputChangedEvent.ts +11 -0
  21. package/src/preact/textInput/fetchAutocompleteList.ts +1 -1
  22. package/src/preact/textInput/text-input.stories.tsx +20 -3
  23. package/src/preact/textInput/text-input.tsx +139 -36
  24. package/src/utilEntrypoint.ts +2 -0
  25. package/src/web-components/input/gs-location-filter.stories.ts +34 -29
  26. package/src/web-components/input/gs-location-filter.tsx +6 -13
  27. package/src/web-components/input/gs-text-input.stories.ts +30 -7
  28. package/standalone-bundle/dashboard-components.js +11073 -8625
  29. package/standalone-bundle/dashboard-components.js.map +1 -1
  30. package/standalone-bundle/style.css +1 -1
  31. package/dist/dateRangeOption-Bh2p78z0.js.map +0 -1
@@ -1,9 +1,11 @@
1
+ import { useCombobox } from 'downshift/preact';
1
2
  import { type FunctionComponent } from 'preact';
2
- import { useContext, useRef } from 'preact/hooks';
3
+ import { useContext, useRef, useState } from 'preact/hooks';
3
4
  import z from 'zod';
4
5
 
5
6
  import { fetchAutocompleteList } from './fetchAutocompleteList';
6
7
  import { LapisUrlContext } from '../LapisUrlContext';
8
+ import { TextInputChangedEvent } from './TextInputChangedEvent';
7
9
  import { ErrorBoundary } from '../components/error-boundary';
8
10
  import { LoadingDisplay } from '../components/loading-display';
9
11
  import { NoDataDisplay } from '../components/no-data-display';
@@ -36,11 +38,9 @@ export const TextInput: FunctionComponent<TextInputProps> = (props) => {
36
38
  );
37
39
  };
38
40
 
39
- const TextInputInner: FunctionComponent<TextInputInnerProps> = ({ lapisField, placeholderText, initialValue }) => {
41
+ const TextInputInner: FunctionComponent<TextInputInnerProps> = ({ initialValue, lapisField, placeholderText }) => {
40
42
  const lapis = useContext(LapisUrlContext);
41
43
 
42
- const inputRef = useRef<HTMLInputElement>(null);
43
-
44
44
  const { data, error, isLoading } = useQuery(() => fetchAutocompleteList(lapis, lapisField), [lapisField, lapis]);
45
45
 
46
46
  if (isLoading) {
@@ -55,43 +55,146 @@ const TextInputInner: FunctionComponent<TextInputInnerProps> = ({ lapisField, pl
55
55
  return <NoDataDisplay />;
56
56
  }
57
57
 
58
- const onInput = () => {
59
- const value = inputRef.current?.value === '' ? undefined : inputRef.current?.value;
60
-
61
- if (isValidValue(value)) {
62
- inputRef.current?.dispatchEvent(
63
- new CustomEvent('gs-text-input-changed', {
64
- detail: { [lapisField]: value },
65
- bubbles: true,
66
- composed: true,
67
- }),
68
- );
69
- }
70
- };
58
+ return (
59
+ <TextSelector
60
+ lapisField={lapisField}
61
+ initialValue={initialValue}
62
+ placeholderText={placeholderText}
63
+ data={data}
64
+ />
65
+ );
66
+ };
71
67
 
72
- const isValidValue = (value: string | undefined) => {
73
- if (value === undefined) {
68
+ const TextSelector = ({
69
+ lapisField,
70
+ initialValue,
71
+ placeholderText,
72
+ data,
73
+ }: TextInputInnerProps & {
74
+ data: string[];
75
+ }) => {
76
+ const [items, setItems] = useState(data.filter((item) => filterByInputValue(item, initialValue)));
77
+
78
+ const divRef = useRef<HTMLDivElement>(null);
79
+
80
+ const shadowRoot = divRef.current?.shadowRoot ?? undefined;
81
+
82
+ const environment =
83
+ shadowRoot !== undefined
84
+ ? {
85
+ addEventListener: window.addEventListener.bind(window),
86
+ removeEventListener: window.removeEventListener.bind(window),
87
+ document: shadowRoot.ownerDocument,
88
+ Node: window.Node,
89
+ }
90
+ : undefined;
91
+
92
+ function filterByInputValue(item: string, inputValue: string | undefined | null) {
93
+ if (inputValue === undefined || inputValue === null || inputValue === '') {
74
94
  return true;
75
95
  }
76
- return data.includes(value);
96
+ return item?.toLowerCase().includes(inputValue?.toLowerCase() || '');
97
+ }
98
+
99
+ const {
100
+ isOpen,
101
+ getToggleButtonProps,
102
+ getMenuProps,
103
+ getInputProps,
104
+ highlightedIndex,
105
+ getItemProps,
106
+ selectedItem,
107
+ inputValue,
108
+ selectItem,
109
+ setInputValue,
110
+ closeMenu,
111
+ } = useCombobox({
112
+ onInputValueChange({ inputValue }) {
113
+ setItems(data.filter((item) => filterByInputValue(item, inputValue)));
114
+ },
115
+ onSelectedItemChange({ selectedItem }) {
116
+ if (selectedItem !== null) {
117
+ divRef.current?.dispatchEvent(new TextInputChangedEvent({ [lapisField]: selectedItem }));
118
+ }
119
+ },
120
+ items,
121
+ itemToString(item) {
122
+ return item ?? '';
123
+ },
124
+ initialSelectedItem: initialValue,
125
+ environment,
126
+ });
127
+
128
+ const onInputBlur = () => {
129
+ if (inputValue === '') {
130
+ divRef.current?.dispatchEvent(new TextInputChangedEvent({ [lapisField]: undefined }));
131
+ selectItem(null);
132
+ } else if (inputValue !== selectedItem) {
133
+ setInputValue(selectedItem ?? '');
134
+ }
77
135
  };
78
136
 
137
+ const clearInput = () => {
138
+ divRef.current?.dispatchEvent(new TextInputChangedEvent({ [lapisField]: undefined }));
139
+ selectItem(null);
140
+ };
141
+
142
+ const buttonRef = useRef(null);
143
+
79
144
  return (
80
- <>
81
- <input
82
- type='text'
83
- class='input input-bordered w-full'
84
- placeholder={placeholderText ?? lapisField}
85
- onInput={onInput}
86
- ref={inputRef}
87
- list={lapisField}
88
- value={initialValue}
89
- />
90
- <datalist id={lapisField}>
91
- {data.map((item) => (
92
- <option value={item} key={item} />
93
- ))}
94
- </datalist>
95
- </>
145
+ <div ref={divRef} className={'relative w-full'}>
146
+ <div className='w-full flex flex-col gap-1'>
147
+ <div
148
+ className='flex gap-0.5 input input-bordered min-w-32'
149
+ onBlur={(event) => {
150
+ if (event.relatedTarget != buttonRef.current) {
151
+ closeMenu();
152
+ }
153
+ }}
154
+ >
155
+ <input
156
+ placeholder={placeholderText}
157
+ className='w-full p-1.5'
158
+ {...getInputProps()}
159
+ onBlur={onInputBlur}
160
+ />
161
+ <button
162
+ aria-label='clear selection'
163
+ className={`px-2 ${inputValue === '' && 'hidden'}`}
164
+ type='button'
165
+ onClick={clearInput}
166
+ tabIndex={-1}
167
+ >
168
+ ×
169
+ </button>
170
+ <button
171
+ aria-label='toggle menu'
172
+ className='px-2'
173
+ type='button'
174
+ {...getToggleButtonProps()}
175
+ ref={buttonRef}
176
+ >
177
+ {isOpen ? <>↑</> : <>↓</>}
178
+ </button>
179
+ </div>
180
+ </div>
181
+ <ul
182
+ className={`absolute bg-white mt-1 shadow-md max-h-80 overflow-scroll z-10 w-full min-w-32 ${
183
+ !(isOpen && items.length > 0) && 'hidden'
184
+ }`}
185
+ {...getMenuProps()}
186
+ >
187
+ {isOpen &&
188
+ items.map((item, index) => (
189
+ <li
190
+ className={`${highlightedIndex === index && 'bg-blue-300'} ${selectedItem !== null} py-2 px-3 shadow-sm flex flex-col`}
191
+ key={item}
192
+ {...getItemProps({ item, index })}
193
+ >
194
+ <span>{item}</span>
195
+ </li>
196
+ ))}
197
+ </ul>
198
+ </div>
96
199
  );
97
200
  };
@@ -32,3 +32,5 @@ export type { MapSource } from './preact/map/loadMapSource';
32
32
  export type { ConfidenceIntervalMethod } from './preact/shared/charts/confideceInterval';
33
33
 
34
34
  export type { AxisMax, YAxisMaxConfig } from './preact/shared/charts/getYAxisMax';
35
+
36
+ export { LocationChangedEvent } from './preact/locationFilter/LocationChangedEvent';
@@ -15,7 +15,7 @@ import { withinShadowRoot } from '../withinShadowRoot.story';
15
15
  const codeExample = String.raw`
16
16
  <gs-location-filter
17
17
  fields='["region", "country"]'
18
- initialValue='Europe / Switzerland'
18
+ value='{ "region": "Europe", "country": null}'
19
19
  width="100%"
20
20
  placeholderText="Enter a location"
21
21
  ></gs-location-filter>`;
@@ -39,9 +39,9 @@ const meta: Meta = {
39
39
  type: 'object',
40
40
  },
41
41
  },
42
- initialValue: {
42
+ value: {
43
43
  control: {
44
- type: 'text',
44
+ type: 'object',
45
45
  },
46
46
  },
47
47
  width: {
@@ -66,7 +66,7 @@ const Template: StoryObj<LocationFilterProps> = {
66
66
  <div class="max-w-screen-lg">
67
67
  <gs-location-filter
68
68
  .fields=${args.fields}
69
- initialValue=${ifDefined(args.initialValue)}
69
+ .value=${args.value}
70
70
  .width=${args.width}
71
71
  placeholderText=${ifDefined(args.placeholderText)}
72
72
  ></gs-location-filter>
@@ -75,7 +75,7 @@ const Template: StoryObj<LocationFilterProps> = {
75
75
  },
76
76
  args: {
77
77
  fields: ['region', 'country', 'division', 'location'],
78
- initialValue: '',
78
+ value: undefined,
79
79
  width: '100%',
80
80
  placeholderText: 'Enter a location',
81
81
  },
@@ -107,10 +107,7 @@ export const LocationFilter: StoryObj<LocationFilterProps> = {
107
107
  play: async ({ canvasElement }) => {
108
108
  const canvas = await withinShadowRoot(canvasElement, 'gs-location-filter');
109
109
  await waitFor(() => {
110
- return expect(canvas.getByRole('combobox')).toBeEnabled();
111
- });
112
- await waitFor(() => {
113
- return expect(canvas.getByPlaceholderText('Enter a location')).toBeInTheDocument();
110
+ return expect(canvas.getByPlaceholderText('Enter a location')).toBeVisible();
114
111
  });
115
112
  },
116
113
  };
@@ -199,36 +196,44 @@ export const FiresEvent: StoryObj<LocationFilterProps> = {
199
196
 
200
197
  await step('Empty input', async () => {
201
198
  await userEvent.type(inputField(), '{backspace>18/}');
202
- await expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
203
- region: undefined,
204
- country: undefined,
205
- division: undefined,
206
- location: undefined,
199
+ await userEvent.click(canvas.getByLabelText('toggle menu'));
200
+
201
+ await waitFor(() => {
202
+ return expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
203
+ region: undefined,
204
+ country: undefined,
205
+ division: undefined,
206
+ location: undefined,
207
+ });
207
208
  });
208
209
  });
209
210
 
210
211
  await step('Select Asia', async () => {
211
212
  await userEvent.type(inputField(), 'Asia');
212
- await expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
213
- region: 'Asia',
214
- country: undefined,
215
- division: undefined,
216
- location: undefined,
213
+ await userEvent.click(canvas.getByRole('option', { name: 'Asia Asia' }));
214
+
215
+ await waitFor(() => {
216
+ return expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
217
+ region: 'Asia',
218
+ country: undefined,
219
+ division: undefined,
220
+ location: undefined,
221
+ });
217
222
  });
218
223
  });
219
224
 
220
225
  await step('Select Asia / Bangladesh / Rajshahi / Chapainawabgonj', async () => {
221
226
  await userEvent.type(inputField(), ' / Bangladesh / Rajshahi / Chapainawabgonj');
222
- await expect(listenerMock).toHaveBeenCalledWith(
223
- expect.objectContaining({
224
- detail: {
225
- region: 'Asia',
226
- country: 'Bangladesh',
227
- division: 'Rajshahi',
228
- location: 'Chapainawabgonj',
229
- },
230
- }),
231
- );
227
+ await userEvent.click(canvas.getByText('Asia / Bangladesh / Rajshahi / Chapainawabgonj'));
228
+
229
+ await waitFor(() => {
230
+ return expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
231
+ region: 'Asia',
232
+ country: 'Bangladesh',
233
+ division: 'Rajshahi',
234
+ location: 'Chapainawabgonj',
235
+ });
236
+ });
232
237
  });
233
238
  },
234
239
  };
@@ -1,6 +1,7 @@
1
1
  import { customElement, property } from 'lit/decorators.js';
2
2
  import type { DetailedHTMLProps, HTMLAttributes } from 'react';
3
3
 
4
+ import { type LocationChangedEvent } from '../../preact/locationFilter/LocationChangedEvent';
4
5
  import { LocationFilter, type LocationFilterProps } from '../../preact/locationFilter/location-filter';
5
6
  import type { Equals, Expect } from '../../utils/typeAssertions';
6
7
  import { PreactLitAdapter } from '../PreactLitAdapter';
@@ -14,11 +15,6 @@ import { PreactLitAdapter } from '../PreactLitAdapter';
14
15
  * The component retrieves a list of all possible values for these fields from the Lapis instance.
15
16
  * This list is then utilized to display autocomplete suggestions and to validate the input.
16
17
  *
17
- * Given `fields` are `['field1', 'field2', ..., 'fieldN']`,
18
- * then valid values for the location filter must be in the form `valueForField1 / valueForField2 / ... / valueForFieldK`,
19
- * where `1 <= K <= N`.
20
- * Values for the fields `i > K` are considered `undefined`.
21
- *
22
18
  * @fires {CustomEvent<Record<string, string>>} gs-location-changed
23
19
  * Fired when a value from the datalist is selected or when a valid value is typed into the field.
24
20
  * The `details` of this event contain an object with all `fields` as keys
@@ -37,10 +33,9 @@ import { PreactLitAdapter } from '../PreactLitAdapter';
37
33
  export class LocationFilterComponent extends PreactLitAdapter {
38
34
  /**
39
35
  * The initial value to use for this location filter.
40
- * Must be of the form `valueForField1 / valueForField2 / ... / valueForFieldN`.
41
36
  */
42
- @property()
43
- initialValue: string | undefined = undefined;
37
+ @property({ type: Object })
38
+ value: Record<string, string | null | undefined> | undefined = undefined;
44
39
 
45
40
  /**
46
41
  * Required.
@@ -70,7 +65,7 @@ export class LocationFilterComponent extends PreactLitAdapter {
70
65
  override render() {
71
66
  return (
72
67
  <LocationFilter
73
- initialValue={this.initialValue}
68
+ value={this.value}
74
69
  fields={this.fields}
75
70
  width={this.width}
76
71
  placeholderText={this.placeholderText}
@@ -85,7 +80,7 @@ declare global {
85
80
  }
86
81
 
87
82
  interface HTMLElementEventMap {
88
- 'gs-location-changed': CustomEvent<Record<string, string>>;
83
+ 'gs-location-changed': LocationChangedEvent;
89
84
  }
90
85
  }
91
86
 
@@ -99,9 +94,7 @@ declare global {
99
94
  }
100
95
 
101
96
  /* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars */
102
- type InitialValueMatches = Expect<
103
- Equals<typeof LocationFilterComponent.prototype.initialValue, LocationFilterProps['initialValue']>
104
- >;
97
+ type InitialValueMatches = Expect<Equals<typeof LocationFilterComponent.prototype.value, LocationFilterProps['value']>>;
105
98
  type FieldsMatches = Expect<Equals<typeof LocationFilterComponent.prototype.fields, LocationFilterProps['fields']>>;
106
99
  type PlaceholderTextMatches = Expect<
107
100
  Equals<typeof LocationFilterComponent.prototype.placeholderText, LocationFilterProps['placeholderText']>
@@ -1,4 +1,4 @@
1
- import { expect, fn, userEvent, waitFor } from '@storybook/test';
1
+ import { expect, fireEvent, fn, userEvent, waitFor } from '@storybook/test';
2
2
  import type { Meta, StoryObj } from '@storybook/web-components';
3
3
  import { html } from 'lit';
4
4
 
@@ -97,7 +97,7 @@ export const Default: StoryObj<Required<TextInputProps>> = {
97
97
  },
98
98
  };
99
99
 
100
- export const FiresEvent: StoryObj<Required<TextInputProps>> = {
100
+ export const FiresEvents: StoryObj<Required<TextInputProps>> = {
101
101
  ...Default,
102
102
  play: async ({ canvasElement, step }) => {
103
103
  const canvas = await withinShadowRoot(canvasElement, 'gs-text-input');
@@ -121,22 +121,45 @@ export const FiresEvent: StoryObj<Required<TextInputProps>> = {
121
121
 
122
122
  await step('Empty input', async () => {
123
123
  await userEvent.type(inputField(), '{backspace>9/}');
124
- await expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
125
- host: undefined,
126
- });
127
124
  });
128
125
 
129
126
  await step('Enter a valid host name', async () => {
130
- await userEvent.type(inputField(), 'Homo');
127
+ await userEvent.type(inputField(), 'Homo s');
128
+
129
+ const dropdownOption = await canvas.findByText('Homo sapiens');
130
+ await userEvent.click(dropdownOption);
131
+ });
132
+
133
+ await step('Verify event is fired with correct detail', async () => {
134
+ await waitFor(() => {
135
+ expect(listenerMock).toHaveBeenCalledWith(
136
+ expect.objectContaining({
137
+ detail: {
138
+ host: 'Homo sapiens',
139
+ },
140
+ }),
141
+ );
142
+ });
143
+ });
144
+
145
+ await step('Remove initial value', async () => {
146
+ await fireEvent.click(canvas.getByRole('button', { name: 'clear selection' }));
131
147
 
132
148
  await expect(listenerMock).toHaveBeenCalledWith(
133
149
  expect.objectContaining({
134
150
  detail: {
135
- host: 'Homo',
151
+ host: undefined,
136
152
  },
137
153
  }),
138
154
  );
139
155
  });
156
+
157
+ await step('Empty input', async () => {
158
+ inputField().blur();
159
+ await expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
160
+ host: undefined,
161
+ });
162
+ });
140
163
  },
141
164
  args: {
142
165
  ...Default.args,