@genspectrum/dashboard-components 0.19.2 → 0.19.4

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 (61) hide show
  1. package/custom-elements.json +383 -10
  2. package/dist/{LineageFilterChangedEvent-ixHQkq8y.js → LineageFilterChangedEvent-GgkxoF3X.js} +17 -5
  3. package/dist/LineageFilterChangedEvent-GgkxoF3X.js.map +1 -0
  4. package/dist/assets/mutationOverTimeWorker-ChQTFL68.js.map +1 -1
  5. package/dist/components.d.ts +184 -21
  6. package/dist/components.js +9352 -8683
  7. package/dist/components.js.map +1 -1
  8. package/dist/util.d.ts +69 -21
  9. package/dist/util.js +2 -1
  10. package/package.json +1 -1
  11. package/src/componentsEntrypoint.ts +3 -1
  12. package/src/preact/components/error-display.stories.tsx +2 -1
  13. package/src/preact/components/error-display.tsx +2 -3
  14. package/src/preact/components/min-max-range-slider.tsx +19 -4
  15. package/src/preact/components/resize-container.tsx +7 -10
  16. package/src/preact/components/tooltip.tsx +7 -4
  17. package/src/preact/dateRangeFilter/date-range-filter.stories.tsx +9 -5
  18. package/src/preact/dateRangeFilter/date-range-filter.tsx +2 -1
  19. package/src/preact/dateRangeFilter/dateRangeOption.ts +2 -1
  20. package/src/preact/genomeViewer/CDSPlot.tsx +219 -0
  21. package/src/preact/genomeViewer/genome-data-viewer.stories.tsx +113 -0
  22. package/src/preact/genomeViewer/genome-data-viewer.tsx +69 -0
  23. package/src/preact/genomeViewer/loadGff3.spec.ts +61 -0
  24. package/src/preact/genomeViewer/loadGff3.ts +180 -0
  25. package/src/preact/lineageFilter/LineageFilterChangedEvent.ts +3 -1
  26. package/src/preact/lineageFilter/lineage-filter.stories.tsx +3 -2
  27. package/src/preact/locationFilter/LocationChangedEvent.ts +2 -1
  28. package/src/preact/locationFilter/location-filter.stories.tsx +3 -2
  29. package/src/preact/mutationFilter/mutation-filter.stories.tsx +3 -2
  30. package/src/preact/mutationFilter/mutation-filter.tsx +2 -1
  31. package/src/preact/numberRangeFilter/NumberRangeFilterChangedEvent.ts +31 -0
  32. package/src/preact/numberRangeFilter/number-range-filter.stories.tsx +383 -0
  33. package/src/preact/numberRangeFilter/number-range-filter.tsx +159 -0
  34. package/src/preact/numberRangeFilter/useSelectedRangeReducer.ts +137 -0
  35. package/src/preact/shared/charts/colors.ts +1 -1
  36. package/src/preact/textFilter/TextFilterChangedEvent.ts +3 -1
  37. package/src/preact/textFilter/text-filter.stories.tsx +4 -3
  38. package/src/utilEntrypoint.ts +2 -0
  39. package/src/utils/gsEventNames.ts +11 -0
  40. package/src/web-components/input/gs-date-range-filter.stories.ts +4 -3
  41. package/src/web-components/input/gs-date-range-filter.tsx +3 -2
  42. package/src/web-components/input/gs-lineage-filter.stories.ts +3 -2
  43. package/src/web-components/input/gs-lineage-filter.tsx +2 -1
  44. package/src/web-components/input/gs-location-filter.stories.ts +3 -2
  45. package/src/web-components/input/gs-location-filter.tsx +2 -1
  46. package/src/web-components/input/gs-mutation-filter.stories.ts +3 -2
  47. package/src/web-components/input/gs-mutation-filter.tsx +2 -1
  48. package/src/web-components/input/gs-number-range-filter.spec.ts +27 -0
  49. package/src/web-components/input/gs-number-range-filter.stories.ts +96 -0
  50. package/src/web-components/input/gs-number-range-filter.tsx +148 -0
  51. package/src/web-components/input/gs-text-filter.stories.ts +5 -4
  52. package/src/web-components/input/gs-text-filter.tsx +2 -1
  53. package/src/web-components/input/index.ts +1 -0
  54. package/src/web-components/visualization/gs-genome-data-viewer.spec-d.ts +18 -0
  55. package/src/web-components/visualization/gs-genome-data-viewer.stories.ts +108 -0
  56. package/src/web-components/visualization/gs-genome-data-viewer.tsx +59 -0
  57. package/src/web-components/visualization/index.ts +1 -0
  58. package/standalone-bundle/assets/mutationOverTimeWorker-jChgWnwp.js.map +1 -1
  59. package/standalone-bundle/dashboard-components.js +9613 -9059
  60. package/standalone-bundle/dashboard-components.js.map +1 -1
  61. package/dist/LineageFilterChangedEvent-ixHQkq8y.js.map +0 -1
@@ -1,8 +1,9 @@
1
1
  import { type LapisLocationFilter } from '../../types';
2
+ import { gsEventNames } from '../../utils/gsEventNames';
2
3
 
3
4
  export class LocationChangedEvent extends CustomEvent<LapisLocationFilter> {
4
5
  constructor(detail: LapisLocationFilter) {
5
- super('gs-location-changed', {
6
+ super(gsEventNames.locationChanged, {
6
7
  detail,
7
8
  bubbles: true,
8
9
  composed: true,
@@ -6,6 +6,7 @@ import data from './__mockData__/aggregated.json';
6
6
  import { LocationFilter, type LocationFilterProps } from './location-filter';
7
7
  import { previewHandles } from '../../../.storybook/preview';
8
8
  import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
9
+ import { gsEventNames } from '../../utils/gsEventNames';
9
10
  import { LapisUrlContextProvider } from '../LapisUrlContext';
10
11
  import { expectInvalidAttributesErrorMessage } from '../shared/stories/expectErrorMessage';
11
12
 
@@ -32,7 +33,7 @@ const meta: Meta<LocationFilterProps> = {
32
33
  ],
33
34
  },
34
35
  actions: {
35
- handles: ['gs-location-changed', ...previewHandles],
36
+ handles: [gsEventNames.locationChanged, ...previewHandles],
36
37
  },
37
38
  },
38
39
  args: {
@@ -152,7 +153,7 @@ async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRend
152
153
 
153
154
  const locationChangedListenerMock = fn();
154
155
  await step('Setup event listener mock', () => {
155
- canvasElement.addEventListener('gs-location-changed', locationChangedListenerMock);
156
+ canvasElement.addEventListener(gsEventNames.locationChanged, locationChangedListenerMock);
156
157
  });
157
158
 
158
159
  await step('location filter is rendered with value', async () => {
@@ -6,6 +6,7 @@ import { MutationFilter, type MutationFilterProps } from './mutation-filter';
6
6
  import { previewHandles } from '../../../.storybook/preview';
7
7
  import { LAPIS_URL } from '../../constants';
8
8
  import referenceGenome from '../../lapisApi/__mockData__/referenceGenome.json';
9
+ import { gsEventNames } from '../../utils/gsEventNames';
9
10
  import { LapisUrlContextProvider } from '../LapisUrlContext';
10
11
  import { ReferenceGenomeContext } from '../ReferenceGenomeContext';
11
12
  import { playThatExpectsErrorMessage } from '../shared/stories/expectErrorMessage';
@@ -15,7 +16,7 @@ const meta: Meta<MutationFilterProps> = {
15
16
  component: MutationFilter,
16
17
  parameters: {
17
18
  actions: {
18
- handles: ['gs-mutation-filter-changed', ...previewHandles],
19
+ handles: [gsEventNames.mutationFilterChanged, ...previewHandles],
19
20
  },
20
21
  fetchMock: {},
21
22
  },
@@ -357,7 +358,7 @@ async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRend
357
358
 
358
359
  const changedListenerMock = fn();
359
360
  await step('Setup event listener mock', () => {
360
- canvasElement.addEventListener('gs-mutation-filter-changed', changedListenerMock);
361
+ canvasElement.addEventListener(gsEventNames.mutationFilterChanged, changedListenerMock);
361
362
  });
362
363
 
363
364
  await step('wait until data is loaded', async () => {
@@ -8,6 +8,7 @@ import { MutationFilterInfo } from './mutation-filter-info';
8
8
  import { parseAndValidateMutation } from './parseAndValidateMutation';
9
9
  import { type ReferenceGenome } from '../../lapisApi/ReferenceGenome';
10
10
  import { type MutationsFilter, mutationsFilterSchema } from '../../types';
11
+ import { gsEventNames } from '../../utils/gsEventNames';
11
12
  import { type DeletionClass, type InsertionClass, type SubstitutionClass } from '../../utils/mutations';
12
13
  import { ReferenceGenomeContext } from '../ReferenceGenomeContext';
13
14
  import { ErrorBoundary } from '../components/error-boundary';
@@ -86,7 +87,7 @@ function MutationFilterInner({ initialValue }: MutationFilterInnerProps) {
86
87
  const detail = mapToMutationFilterStrings(selectedFilters);
87
88
 
88
89
  filterRef.current?.dispatchEvent(
89
- new CustomEvent<MutationsFilter>('gs-mutation-filter-changed', {
90
+ new CustomEvent<MutationsFilter>(gsEventNames.mutationFilterChanged, {
90
91
  detail,
91
92
  bubbles: true,
92
93
  composed: true,
@@ -0,0 +1,31 @@
1
+ import z from 'zod';
2
+
3
+ import { gsEventNames } from '../../utils/gsEventNames';
4
+
5
+ type LapisNumberFilter = Record<string, number | undefined>;
6
+
7
+ export const numberRangeSchema = z.object({
8
+ min: z.number().optional(),
9
+ max: z.number().optional(),
10
+ });
11
+ export type NumberRange = z.infer<typeof numberRangeSchema>;
12
+
13
+ export class NumberRangeValueChangedEvent extends CustomEvent<NumberRange> {
14
+ constructor(detail: NumberRange) {
15
+ super(gsEventNames.numberRangeValueChanged, {
16
+ detail,
17
+ bubbles: true,
18
+ composed: true,
19
+ });
20
+ }
21
+ }
22
+
23
+ export class NumberRangeFilterChangedEvent extends CustomEvent<LapisNumberFilter> {
24
+ constructor(detail: LapisNumberFilter) {
25
+ super(gsEventNames.numberRangeFilterChanged, {
26
+ detail,
27
+ bubbles: true,
28
+ composed: true,
29
+ });
30
+ }
31
+ }
@@ -0,0 +1,383 @@
1
+ import { type Meta, type StoryObj } from '@storybook/preact';
2
+ import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
3
+ import { type StepFunction } from '@storybook/types';
4
+ import { useEffect, useRef, useState } from 'preact/hooks';
5
+
6
+ import { type NumberRange } from './NumberRangeFilterChangedEvent';
7
+ import { NumberRangeFilter, type NumberRangeFilterProps } from './number-range-filter';
8
+ import { gsEventNames } from '../../utils/gsEventNames';
9
+ import { expectInvalidAttributesErrorMessage } from '../shared/stories/expectErrorMessage';
10
+
11
+ const meta: Meta<NumberRangeFilterProps> = {
12
+ title: 'Input/Number range filter',
13
+ component: NumberRangeFilter,
14
+ parameters: {
15
+ fetchMock: {},
16
+ actions: {
17
+ handles: [gsEventNames.numberRangeFilterChanged, gsEventNames.numberRangeValueChanged],
18
+ },
19
+ },
20
+ argTypes: {
21
+ value: {
22
+ control: {
23
+ type: 'object',
24
+ },
25
+ },
26
+ lapisField: {
27
+ control: {
28
+ type: 'text',
29
+ },
30
+ },
31
+ sliderMin: {
32
+ control: {
33
+ type: 'number',
34
+ },
35
+ },
36
+ sliderMax: {
37
+ control: {
38
+ type: 'number',
39
+ },
40
+ },
41
+ sliderStep: {
42
+ control: {
43
+ type: 'number',
44
+ },
45
+ },
46
+ width: {
47
+ control: {
48
+ type: 'text',
49
+ },
50
+ },
51
+ },
52
+ };
53
+
54
+ export default meta;
55
+
56
+ const Template: StoryObj<NumberRangeFilterProps> = {
57
+ render: (args) => <NumberRangeFilter {...args} />,
58
+ args: {
59
+ lapisField: 'age',
60
+ value: { min: 10, max: 90 },
61
+ sliderMin: 0,
62
+ sliderMax: 100,
63
+ sliderStep: 0.1,
64
+ width: '100%',
65
+ },
66
+ };
67
+
68
+ export const SetMinValue: StoryObj<NumberRangeFilterProps> = {
69
+ ...Template,
70
+ play: async ({ canvasElement, step }) => {
71
+ const {
72
+ filterChangedListenerMock,
73
+ valueChangedListenerMock,
74
+ expectFilterEventWithDetail,
75
+ expectValueEventWithDetail,
76
+ minInput,
77
+ maxInput,
78
+ clearMinButton,
79
+ } = await setup(canvasElement, step);
80
+
81
+ async function changeFocus() {
82
+ await userEvent.click(maxInput());
83
+ }
84
+
85
+ await step('clear min input', async () => {
86
+ await userEvent.click(clearMinButton());
87
+ await waitFor(async () => {
88
+ await expect(minInput()).toHaveValue('');
89
+ await expect(maxInput()).toHaveValue('90');
90
+ });
91
+ await expectFilterEventWithDetail({
92
+ ageFrom: undefined,
93
+ ageTo: 90,
94
+ });
95
+ await expectValueEventWithDetail({
96
+ min: undefined,
97
+ max: 90,
98
+ });
99
+ });
100
+
101
+ await step('type a value into min input', async () => {
102
+ await userEvent.type(minInput(), '9');
103
+ await changeFocus();
104
+ await waitFor(async () => {
105
+ await expect(minInput()).toHaveValue('9');
106
+ await expect(maxInput()).toHaveValue('90');
107
+ });
108
+ await expectFilterEventWithDetail({
109
+ ageFrom: 9,
110
+ ageTo: 90,
111
+ });
112
+ await expectValueEventWithDetail({
113
+ min: 9,
114
+ max: 90,
115
+ });
116
+ });
117
+
118
+ await step('make min input larger than max input and check that no event it dispatched', async () => {
119
+ filterChangedListenerMock.mockClear();
120
+ valueChangedListenerMock.mockClear();
121
+ await userEvent.type(minInput(), '8');
122
+ await changeFocus();
123
+ await waitFor(async () => {
124
+ await expect(minInput()).toHaveValue('98');
125
+ await expect(maxInput()).toHaveValue('90');
126
+ });
127
+ await expect(filterChangedListenerMock).not.toHaveBeenCalled();
128
+ await expect(valueChangedListenerMock).not.toHaveBeenCalled();
129
+ });
130
+ },
131
+ };
132
+
133
+ export const SetMaxValue: StoryObj<NumberRangeFilterProps> = {
134
+ ...Template,
135
+ play: async ({ canvasElement, step }) => {
136
+ const {
137
+ filterChangedListenerMock,
138
+ valueChangedListenerMock,
139
+ expectFilterEventWithDetail,
140
+ expectValueEventWithDetail,
141
+ minInput,
142
+ maxInput,
143
+ clearMaxButton,
144
+ } = await setup(canvasElement, step);
145
+
146
+ async function changeFocus() {
147
+ await userEvent.click(minInput());
148
+ }
149
+
150
+ await step('clear max input', async () => {
151
+ await userEvent.click(clearMaxButton());
152
+ await waitFor(async () => {
153
+ await expect(minInput()).toHaveValue('10');
154
+ await expect(maxInput()).toHaveValue('');
155
+ });
156
+ await expectFilterEventWithDetail({
157
+ ageFrom: 10,
158
+ ageTo: undefined,
159
+ });
160
+ await expectValueEventWithDetail({
161
+ min: 10,
162
+ max: undefined,
163
+ });
164
+ });
165
+
166
+ await step('type a smaller value than min into max input and check that no event is dispatched', async () => {
167
+ filterChangedListenerMock.mockClear();
168
+ valueChangedListenerMock.mockClear();
169
+ await userEvent.type(maxInput(), '1');
170
+ await waitFor(async () => {
171
+ await expect(minInput()).toHaveValue('10');
172
+ await expect(maxInput()).toHaveValue('1');
173
+ });
174
+ await expect(filterChangedListenerMock).not.toHaveBeenCalled();
175
+ await expect(valueChangedListenerMock).not.toHaveBeenCalled();
176
+ });
177
+
178
+ await step('type another value into max input', async () => {
179
+ await userEvent.type(maxInput(), '2');
180
+ await changeFocus();
181
+ await waitFor(async () => {
182
+ await expect(minInput()).toHaveValue('10');
183
+ await expect(maxInput()).toHaveValue('12');
184
+ });
185
+ await expectFilterEventWithDetail({
186
+ ageFrom: 10,
187
+ ageTo: 12,
188
+ });
189
+ await expectValueEventWithDetail({
190
+ min: 10,
191
+ max: 12,
192
+ });
193
+ });
194
+ },
195
+ };
196
+
197
+ export const TypeInvalidNumbers: StoryObj<NumberRangeFilterProps> = {
198
+ ...Template,
199
+ play: async ({ canvasElement, step }) => {
200
+ const { filterChangedListenerMock, valueChangedListenerMock, minInput, maxInput, clearMaxButton } = await setup(
201
+ canvasElement,
202
+ step,
203
+ );
204
+
205
+ await step('clear max input', async () => {
206
+ await userEvent.click(clearMaxButton());
207
+ await waitFor(() => expect(filterChangedListenerMock).toHaveBeenCalled());
208
+ await waitFor(() => expect(valueChangedListenerMock).toHaveBeenCalled());
209
+ filterChangedListenerMock.mockClear();
210
+ valueChangedListenerMock.mockClear();
211
+ });
212
+
213
+ await step('type invalid number into input field', async () => {
214
+ await expect(minInput()).toBeValid();
215
+ await expect(maxInput()).toBeValid();
216
+ await userEvent.type(maxInput(), 'not a number');
217
+ await waitFor(async () => {
218
+ await expect(minInput()).toHaveValue('10');
219
+ await expect(maxInput()).toHaveValue('not a number');
220
+ await expect(minInput()).toBeInvalid();
221
+ await expect(maxInput()).toBeInvalid();
222
+ });
223
+ await expect(filterChangedListenerMock).not.toHaveBeenCalled();
224
+ await expect(valueChangedListenerMock).not.toHaveBeenCalled();
225
+ });
226
+
227
+ await step('clear invalid input from input field', async () => {
228
+ await expect(clearMaxButton()).toBeVisible();
229
+ await userEvent.click(clearMaxButton());
230
+ await waitFor(async () => {
231
+ await expect(minInput()).toHaveValue('10');
232
+ await expect(maxInput()).toHaveValue('');
233
+ await expect(minInput()).toBeValid();
234
+ await expect(maxInput()).toBeValid();
235
+ });
236
+ });
237
+ },
238
+ };
239
+
240
+ export const WithInvalidProps: StoryObj<NumberRangeFilterProps> = {
241
+ ...Template,
242
+ args: {
243
+ ...Template.args,
244
+ lapisField: '',
245
+ },
246
+ play: async ({ canvasElement, step }) => {
247
+ await step('expect error message', async () => {
248
+ await expectInvalidAttributesErrorMessage(canvasElement, 'String must contain at least 1 character(s)');
249
+ });
250
+ },
251
+ };
252
+
253
+ export const ChangingTheValueProgrammatically: StoryObj<NumberRangeFilterProps> = {
254
+ ...Template,
255
+ render: (args) => {
256
+ const StatefulWrapper = () => {
257
+ const [value, setValue] = useState<NumberRange>({
258
+ min: 10,
259
+ max: 90,
260
+ });
261
+ const ref = useRef<HTMLDivElement>(null);
262
+
263
+ useEffect(() => {
264
+ ref.current?.addEventListener(gsEventNames.numberRangeValueChanged, (event) => {
265
+ setValue(event.detail);
266
+ });
267
+ }, []);
268
+ return (
269
+ <div ref={ref}>
270
+ <NumberRangeFilter {...args} value={value} />
271
+ <button className='btn' onClick={() => setValue((prev) => ({ ...prev, min: 30 }))}>
272
+ Set min to 30
273
+ </button>
274
+ <button className='btn' onClick={() => setValue((prev) => ({ ...prev, max: 40 }))}>
275
+ Set max to 40
276
+ </button>
277
+ </div>
278
+ );
279
+ };
280
+
281
+ return <StatefulWrapper />;
282
+ },
283
+ play: async ({ canvasElement, step, canvas }) => {
284
+ const {
285
+ filterChangedListenerMock,
286
+ valueChangedListenerMock,
287
+ expectFilterEventWithDetail,
288
+ expectValueEventWithDetail,
289
+ minInput,
290
+ maxInput,
291
+ clearMaxButton,
292
+ } = await setup(canvasElement, step);
293
+
294
+ await waitFor(async () => {
295
+ await expect(minInput()).toHaveValue('10');
296
+ await expect(maxInput()).toHaveValue('90');
297
+ });
298
+
299
+ await userEvent.click(canvas.getByRole('button', { name: 'Set min to 30' }));
300
+ await waitFor(async () => {
301
+ await expect(minInput()).toHaveValue('30');
302
+ await expect(maxInput()).toHaveValue('90');
303
+ });
304
+ await expect(filterChangedListenerMock).not.toHaveBeenCalled();
305
+ await expect(valueChangedListenerMock).not.toHaveBeenCalled();
306
+
307
+ await userEvent.click(canvas.getByRole('button', { name: 'Set max to 40' }));
308
+ await waitFor(async () => {
309
+ await expect(minInput()).toHaveValue('30');
310
+ await expect(maxInput()).toHaveValue('40');
311
+ });
312
+ await expect(filterChangedListenerMock).not.toHaveBeenCalled();
313
+ await expect(valueChangedListenerMock).not.toHaveBeenCalled();
314
+
315
+ await userEvent.click(clearMaxButton());
316
+ await waitFor(async () => {
317
+ await expect(minInput()).toHaveValue('30');
318
+ await expect(maxInput()).toHaveValue('');
319
+ });
320
+ await expectFilterEventWithDetail({ ageFrom: 30, ageTo: undefined });
321
+ await expectValueEventWithDetail({ min: 30, max: undefined });
322
+
323
+ await userEvent.click(canvas.getByRole('button', { name: 'Set max to 40' }));
324
+ await waitFor(async () => {
325
+ await expect(minInput()).toHaveValue('30');
326
+ await expect(maxInput()).toHaveValue('40');
327
+ });
328
+ },
329
+ };
330
+
331
+ async function setup(canvasElement: HTMLElement, step: StepFunction) {
332
+ const canvas = within(canvasElement);
333
+
334
+ const filterChangedListenerMock = fn();
335
+ const valueChangedListenerMock = fn();
336
+ await step('Setup event listener mock', () => {
337
+ canvasElement.addEventListener(gsEventNames.numberRangeFilterChanged, filterChangedListenerMock);
338
+ canvasElement.addEventListener(gsEventNames.numberRangeValueChanged, valueChangedListenerMock);
339
+ });
340
+
341
+ const minInput = () => canvas.getByPlaceholderText('age from');
342
+ const maxInput = () => canvas.getByPlaceholderText('age to');
343
+ const clearMinButton = () => canvas.getByRole('button', { name: 'clear min input' });
344
+ const clearMaxButton = () => canvas.getByRole('button', { name: 'clear max input' });
345
+ const expectFilterEventWithDetail = (detail: { ageFrom?: number; ageTo?: number }) => {
346
+ return waitFor(async () => {
347
+ await expect(filterChangedListenerMock).toHaveBeenLastCalledWith(
348
+ expect.objectContaining({
349
+ detail,
350
+ }),
351
+ );
352
+ });
353
+ };
354
+ const expectValueEventWithDetail = (detail: NumberRange) => {
355
+ return waitFor(async () => {
356
+ await expect(valueChangedListenerMock).toHaveBeenLastCalledWith(
357
+ expect.objectContaining({
358
+ detail,
359
+ }),
360
+ );
361
+ });
362
+ };
363
+
364
+ await step('wait until component has loaded', async () => {
365
+ await waitFor(async () => {
366
+ await expect(minInput()).toBeVisible();
367
+ await expect(maxInput()).toBeVisible();
368
+ await expect(minInput()).toHaveValue('10');
369
+ await expect(maxInput()).toHaveValue('90');
370
+ });
371
+ });
372
+
373
+ return {
374
+ filterChangedListenerMock,
375
+ valueChangedListenerMock,
376
+ expectFilterEventWithDetail,
377
+ expectValueEventWithDetail,
378
+ minInput,
379
+ maxInput,
380
+ clearMinButton,
381
+ clearMaxButton,
382
+ };
383
+ }
@@ -0,0 +1,159 @@
1
+ import { type FunctionComponent } from 'preact';
2
+ import { useEffect, useRef, useState } from 'preact/hooks';
3
+ import z from 'zod';
4
+
5
+ import {
6
+ NumberRangeFilterChangedEvent,
7
+ numberRangeSchema,
8
+ NumberRangeValueChangedEvent,
9
+ } from './NumberRangeFilterChangedEvent';
10
+ import { SetRangeActionType, useSelectedRangeReducer } from './useSelectedRangeReducer';
11
+ import { ErrorBoundary } from '../components/error-boundary';
12
+ import { MinMaxRangeSlider } from '../components/min-max-range-slider';
13
+ import { ResizeContainer } from '../components/resize-container';
14
+ import { DeleteIcon } from '../shared/icons/DeleteIcon';
15
+
16
+ const numberRangeFilterPropsSchema = z.object({
17
+ value: numberRangeSchema,
18
+ lapisField: z.string().min(1),
19
+ sliderMin: z.number(),
20
+ sliderMax: z.number(),
21
+ sliderStep: z.number().positive(),
22
+ width: z.string(),
23
+ });
24
+ export type NumberRangeFilterProps = z.infer<typeof numberRangeFilterPropsSchema>;
25
+
26
+ export const NumberRangeFilter: FunctionComponent<NumberRangeFilterProps> = (props) => {
27
+ const { width, ...innerProps } = props;
28
+ const size = { width, height: '4.8rem' };
29
+
30
+ return (
31
+ <ErrorBoundary size={size} layout='horizontal' componentProps={props} schema={numberRangeFilterPropsSchema}>
32
+ <ResizeContainer size={size}>
33
+ <NumberRangeFilterInner {...innerProps} />
34
+ </ResizeContainer>
35
+ </ErrorBoundary>
36
+ );
37
+ };
38
+
39
+ type NumberRangeFilterInnerProps = Omit<NumberRangeFilterProps, 'width'>;
40
+
41
+ const NumberRangeFilterInner: FunctionComponent<NumberRangeFilterInnerProps> = ({
42
+ value,
43
+ lapisField,
44
+ sliderMin,
45
+ sliderMax,
46
+ sliderStep,
47
+ }) => {
48
+ const divRef = useRef<HTMLDivElement>(null);
49
+
50
+ const [currentRange, dispatchRange] = useSelectedRangeReducer(value);
51
+ const [shouldDispatchEvent, setShouldDispatchEvent] = useState(false);
52
+
53
+ useEffect(() => {
54
+ if (!shouldDispatchEvent || currentRange.wasDispatched) {
55
+ return;
56
+ }
57
+
58
+ setShouldDispatchEvent(false);
59
+ if (currentRange.range.isValidRange) {
60
+ dispatchRange({ type: SetRangeActionType.DISPATCHED_EVENT });
61
+ divRef.current?.dispatchEvent(
62
+ new NumberRangeValueChangedEvent({
63
+ min: currentRange.range.min,
64
+ max: currentRange.range.max,
65
+ }),
66
+ );
67
+ divRef.current?.dispatchEvent(
68
+ new NumberRangeFilterChangedEvent({
69
+ [`${lapisField}From`]: currentRange.range.min,
70
+ [`${lapisField}To`]: currentRange.range.max,
71
+ }),
72
+ );
73
+ }
74
+ }, [divRef, currentRange.range, currentRange.wasDispatched, shouldDispatchEvent, lapisField, dispatchRange]);
75
+
76
+ function scheduleEventDispatch() {
77
+ setShouldDispatchEvent(true);
78
+ }
79
+
80
+ const dispatchRangeWithEvent: typeof dispatchRange = (action) => {
81
+ dispatchRange(action);
82
+ scheduleEventDispatch();
83
+ };
84
+
85
+ const inputError = currentRange.range.isValidRange ? '' : 'input-error';
86
+
87
+ return (
88
+ <div ref={divRef}>
89
+ <div className='join w-full'>
90
+ <div className={`join-item w-full flex input px-2 ${inputError}`}>
91
+ <input
92
+ type='text'
93
+ inputmode='numeric'
94
+ className='w-full grow capitalize'
95
+ placeholder={`${lapisField} from`}
96
+ value={currentRange.inputState.min ?? ''}
97
+ onInput={(e) => {
98
+ dispatchRange({
99
+ type: SetRangeActionType.SET_MIN,
100
+ value: (e.target as HTMLInputElement).value,
101
+ });
102
+ }}
103
+ onBlur={() => scheduleEventDispatch()}
104
+ aria-invalid={!currentRange.range.isValidRange}
105
+ />
106
+ {currentRange.inputState.min !== '' && (
107
+ <button
108
+ onClick={() => dispatchRangeWithEvent({ type: SetRangeActionType.SET_MIN, value: '' })}
109
+ className='cursor-pointer'
110
+ aria-label='clear min input'
111
+ >
112
+ <DeleteIcon />
113
+ </button>
114
+ )}
115
+ </div>
116
+ <div className={`join-item w-full flex input px-2 ${inputError}`}>
117
+ <input
118
+ type='text'
119
+ inputmode='numeric'
120
+ className='w-full grow capitalize'
121
+ placeholder={`${lapisField} to`}
122
+ value={currentRange.inputState.max ?? ''}
123
+ onInput={(e) => {
124
+ dispatchRange({
125
+ type: SetRangeActionType.SET_MAX,
126
+ value: (e.target as HTMLInputElement).value,
127
+ });
128
+ }}
129
+ onBlur={() => scheduleEventDispatch()}
130
+ aria-invalid={!currentRange.range.isValidRange}
131
+ />
132
+ {currentRange.inputState.max !== '' && (
133
+ <button
134
+ onClick={() => dispatchRangeWithEvent({ type: SetRangeActionType.SET_MAX, value: '' })}
135
+ className='cursor-pointer'
136
+ aria-label='clear max input'
137
+ >
138
+ <DeleteIcon />
139
+ </button>
140
+ )}
141
+ </div>
142
+ </div>
143
+ <MinMaxRangeSlider
144
+ min={currentRange.range.min ?? sliderMin}
145
+ max={currentRange.range.max ?? sliderMax}
146
+ rangeMin={sliderMin}
147
+ rangeMax={sliderMax}
148
+ setMin={(min) => {
149
+ dispatchRange({ type: SetRangeActionType.SET_MIN, value: min.toString() });
150
+ }}
151
+ setMax={(max) => {
152
+ dispatchRange({ type: SetRangeActionType.SET_MAX, value: max.toString() });
153
+ }}
154
+ onDrop={() => scheduleEventDispatch()}
155
+ step={sliderStep}
156
+ />
157
+ </div>
158
+ );
159
+ };