@genspectrum/dashboard-components 0.19.3 → 0.19.5
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/custom-elements.json +223 -0
- package/dist/{LineageFilterChangedEvent-b0iuroUL.js → LineageFilterChangedEvent-GgkxoF3X.js} +4 -2
- package/dist/{LineageFilterChangedEvent-b0iuroUL.js.map → LineageFilterChangedEvent-GgkxoF3X.js.map} +1 -1
- package/dist/components.d.ts +137 -20
- package/dist/components.js +589 -241
- package/dist/components.js.map +1 -1
- package/dist/util.d.ts +55 -20
- package/dist/util.js +1 -1
- package/package.json +1 -1
- package/src/preact/components/min-max-range-slider.tsx +19 -4
- package/src/preact/dateRangeFilter/date-range-filter.stories.tsx +4 -1
- package/src/preact/genomeViewer/loadGff3.spec.ts +1 -1
- package/src/preact/genomeViewer/loadGff3.ts +12 -6
- package/src/preact/mutationsOverTime/getFilteredMutationsOverTimeData.ts +4 -2
- package/src/preact/numberRangeFilter/NumberRangeFilterChangedEvent.ts +31 -0
- package/src/preact/numberRangeFilter/number-range-filter.stories.tsx +383 -0
- package/src/preact/numberRangeFilter/number-range-filter.tsx +159 -0
- package/src/preact/numberRangeFilter/useSelectedRangeReducer.ts +137 -0
- package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.stories.tsx +35 -1
- package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.tsx +40 -3
- package/src/utilEntrypoint.ts +2 -0
- package/src/utils/gsEventNames.ts +2 -0
- package/src/web-components/input/gs-number-range-filter.spec.ts +27 -0
- package/src/web-components/input/gs-number-range-filter.stories.ts +96 -0
- package/src/web-components/input/gs-number-range-filter.tsx +148 -0
- package/src/web-components/input/gs-text-filter.stories.ts +2 -2
- package/src/web-components/input/index.ts +1 -0
- package/standalone-bundle/dashboard-components.js +6991 -6688
- package/standalone-bundle/dashboard-components.js.map +1 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useReducer, useState } from 'preact/hooks';
|
|
2
|
+
|
|
3
|
+
import { type NumberRange } from './NumberRangeFilterChangedEvent';
|
|
4
|
+
|
|
5
|
+
type InputState = {
|
|
6
|
+
min: string;
|
|
7
|
+
max: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type RangeState = {
|
|
11
|
+
inputState: InputState;
|
|
12
|
+
range: NumberRange & { isValidRange: boolean };
|
|
13
|
+
wasDispatched: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function useSelectedRangeReducer(initialValue: NumberRange) {
|
|
17
|
+
const [range, dispatchRange] = useReducer(
|
|
18
|
+
rangeReducer,
|
|
19
|
+
addRange({
|
|
20
|
+
min: initialValue.min?.toString() ?? '',
|
|
21
|
+
max: initialValue.max?.toString() ?? '',
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
const [isInitialRender, setIsInitialRender] = useState(true);
|
|
25
|
+
|
|
26
|
+
useEffect(
|
|
27
|
+
() => {
|
|
28
|
+
if (isInitialRender) {
|
|
29
|
+
setIsInitialRender(false);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
dispatchRange({
|
|
34
|
+
type: SetRangeActionType.SET_VALUE_FROM_CONTROLLED_INPUT,
|
|
35
|
+
range: initialValue,
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
[initialValue], // eslint-disable-line react-hooks/exhaustive-deps -- only run this when initialValue changes
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return [range, dispatchRange] as const;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const SetRangeActionType = {
|
|
45
|
+
SET_MIN: 'setMin',
|
|
46
|
+
SET_MAX: 'setMax',
|
|
47
|
+
SET_VALUE_FROM_CONTROLLED_INPUT: 'setValueFromControlledInput',
|
|
48
|
+
DISPATCHED_EVENT: 'dispatchedEvent',
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
type SetRangeAction =
|
|
52
|
+
| {
|
|
53
|
+
type: typeof SetRangeActionType.SET_MIN | typeof SetRangeActionType.SET_MAX;
|
|
54
|
+
value: string;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
type: typeof SetRangeActionType.SET_VALUE_FROM_CONTROLLED_INPUT;
|
|
58
|
+
range: NumberRange;
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
type: typeof SetRangeActionType.DISPATCHED_EVENT;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function rangeReducer(currentState: RangeState, action: SetRangeAction) {
|
|
65
|
+
const { min, max } = currentState.inputState;
|
|
66
|
+
|
|
67
|
+
switch (action.type) {
|
|
68
|
+
case SetRangeActionType.SET_MIN:
|
|
69
|
+
return addRange({ min: action.value, max });
|
|
70
|
+
case SetRangeActionType.SET_MAX:
|
|
71
|
+
return addRange({ min, max: action.value });
|
|
72
|
+
|
|
73
|
+
case SetRangeActionType.SET_VALUE_FROM_CONTROLLED_INPUT:
|
|
74
|
+
return addRange({
|
|
75
|
+
min: action.range.min?.toString() ?? '',
|
|
76
|
+
max: action.range.max?.toString() ?? '',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
case SetRangeActionType.DISPATCHED_EVENT:
|
|
80
|
+
return {
|
|
81
|
+
...currentState,
|
|
82
|
+
wasDispatched: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function addRange(inputState: InputState): RangeState {
|
|
88
|
+
const parsedMin = parseRangeValue(inputState.min);
|
|
89
|
+
const parsedMax = parseRangeValue(inputState.max);
|
|
90
|
+
|
|
91
|
+
const range = {
|
|
92
|
+
min: parsedMin.value,
|
|
93
|
+
max: parsedMax.value,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const isValidRange = parsedMin.valid && parsedMax.valid && isValid(range);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
inputState,
|
|
100
|
+
range: {
|
|
101
|
+
...range,
|
|
102
|
+
isValidRange,
|
|
103
|
+
},
|
|
104
|
+
wasDispatched: false,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseRangeValue(inputValue: string) {
|
|
109
|
+
const trimmedInput = inputValue.trim();
|
|
110
|
+
|
|
111
|
+
if (trimmedInput === '') {
|
|
112
|
+
return {
|
|
113
|
+
valid: true,
|
|
114
|
+
value: undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const value = Number(trimmedInput);
|
|
119
|
+
if (!Number.isFinite(value)) {
|
|
120
|
+
return {
|
|
121
|
+
valid: false,
|
|
122
|
+
value: undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
valid: true,
|
|
128
|
+
value,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isValid(range: NumberRange) {
|
|
133
|
+
if (range.min === undefined || range.max === undefined) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return range.min <= range.max;
|
|
137
|
+
}
|