@genspectrum/dashboard-components 1.9.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/custom-elements.json +55 -7
- package/dist/{NumberRangeFilterChangedEvent-BnPI-Asz.js → NumberRangeFilterChangedEvent-Cdtcp9YL.js} +13 -2
- package/dist/NumberRangeFilterChangedEvent-Cdtcp9YL.js.map +1 -0
- package/dist/components.d.ts +52 -32
- package/dist/components.js +303 -64
- package/dist/components.js.map +1 -1
- package/dist/util.d.ts +29 -27
- package/dist/util.js +1 -1
- package/package.json +1 -1
- package/src/preact/components/downshift-combobox.tsx +277 -47
- package/src/preact/components/mutations-over-time-mutations-filter.stories.tsx +73 -14
- package/src/preact/components/mutations-over-time-mutations-filter.tsx +30 -28
- package/src/preact/lineageFilter/LineageFilterChangedEvent.ts +11 -0
- package/src/preact/lineageFilter/fetchLineageAutocompleteList.ts +2 -2
- package/src/preact/lineageFilter/lineage-filter.stories.tsx +181 -2
- package/src/preact/lineageFilter/lineage-filter.tsx +65 -14
- package/src/utils/gsEventNames.ts +1 -0
- package/src/web-components/input/gs-lineage-filter.spec.ts +30 -0
- package/src/web-components/input/gs-lineage-filter.stories.ts +25 -2
- package/src/web-components/input/gs-lineage-filter.tsx +34 -23
- package/standalone-bundle/dashboard-components.js +6752 -6540
- package/standalone-bundle/dashboard-components.js.map +1 -1
- package/dist/NumberRangeFilterChangedEvent-BnPI-Asz.js.map +0 -1
package/dist/util.d.ts
CHANGED
|
@@ -135,6 +135,7 @@ export declare const gsEventNames: {
|
|
|
135
135
|
readonly dateRangeOptionChanged: "gs-date-range-option-changed";
|
|
136
136
|
readonly mutationFilterChanged: "gs-mutation-filter-changed";
|
|
137
137
|
readonly lineageFilterChanged: "gs-lineage-filter-changed";
|
|
138
|
+
readonly lineageFilterMultiChanged: "gs-lineage-filter-multi-changed";
|
|
138
139
|
readonly locationChanged: "gs-location-changed";
|
|
139
140
|
readonly textFilterChanged: "gs-text-filter-changed";
|
|
140
141
|
readonly numberRangeFilterChanged: "gs-number-range-filter-changed";
|
|
@@ -939,22 +940,6 @@ declare global {
|
|
|
939
940
|
}
|
|
940
941
|
|
|
941
942
|
|
|
942
|
-
declare global {
|
|
943
|
-
interface HTMLElementTagNameMap {
|
|
944
|
-
'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
declare global {
|
|
950
|
-
namespace JSX {
|
|
951
|
-
interface IntrinsicElements {
|
|
952
|
-
'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
|
|
958
943
|
declare global {
|
|
959
944
|
interface HTMLElementTagNameMap {
|
|
960
945
|
'gs-genome-data-viewer': GenomeDataViewerComponent;
|
|
@@ -989,7 +974,7 @@ declare global {
|
|
|
989
974
|
|
|
990
975
|
declare global {
|
|
991
976
|
interface HTMLElementTagNameMap {
|
|
992
|
-
'gs-
|
|
977
|
+
'gs-prevalence-over-time': PrevalenceOverTimeComponent;
|
|
993
978
|
}
|
|
994
979
|
}
|
|
995
980
|
|
|
@@ -997,7 +982,7 @@ declare global {
|
|
|
997
982
|
declare global {
|
|
998
983
|
namespace JSX {
|
|
999
984
|
interface IntrinsicElements {
|
|
1000
|
-
'gs-
|
|
985
|
+
'gs-prevalence-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1001
986
|
}
|
|
1002
987
|
}
|
|
1003
988
|
}
|
|
@@ -1005,7 +990,7 @@ declare global {
|
|
|
1005
990
|
|
|
1006
991
|
declare global {
|
|
1007
992
|
interface HTMLElementTagNameMap {
|
|
1008
|
-
'gs-
|
|
993
|
+
'gs-mutations': MutationsComponent;
|
|
1009
994
|
}
|
|
1010
995
|
}
|
|
1011
996
|
|
|
@@ -1013,7 +998,7 @@ declare global {
|
|
|
1013
998
|
declare global {
|
|
1014
999
|
namespace JSX {
|
|
1015
1000
|
interface IntrinsicElements {
|
|
1016
|
-
'gs-
|
|
1001
|
+
'gs-mutations': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1017
1002
|
}
|
|
1018
1003
|
}
|
|
1019
1004
|
}
|
|
@@ -1117,10 +1102,11 @@ declare global {
|
|
|
1117
1102
|
|
|
1118
1103
|
declare global {
|
|
1119
1104
|
interface HTMLElementTagNameMap {
|
|
1120
|
-
'gs-
|
|
1105
|
+
'gs-date-range-filter': DateRangeFilterComponent;
|
|
1121
1106
|
}
|
|
1122
1107
|
interface HTMLElementEventMap {
|
|
1123
|
-
[gsEventNames.
|
|
1108
|
+
[gsEventNames.dateRangeFilterChanged]: CustomEvent<Record<string, string>>;
|
|
1109
|
+
[gsEventNames.dateRangeOptionChanged]: DateRangeOptionChangedEvent;
|
|
1124
1110
|
}
|
|
1125
1111
|
}
|
|
1126
1112
|
|
|
@@ -1128,7 +1114,7 @@ declare global {
|
|
|
1128
1114
|
declare global {
|
|
1129
1115
|
namespace JSX {
|
|
1130
1116
|
interface IntrinsicElements {
|
|
1131
|
-
'gs-
|
|
1117
|
+
'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1132
1118
|
}
|
|
1133
1119
|
}
|
|
1134
1120
|
}
|
|
@@ -1136,11 +1122,10 @@ declare global {
|
|
|
1136
1122
|
|
|
1137
1123
|
declare global {
|
|
1138
1124
|
interface HTMLElementTagNameMap {
|
|
1139
|
-
'gs-
|
|
1125
|
+
'gs-location-filter': LocationFilterComponent;
|
|
1140
1126
|
}
|
|
1141
1127
|
interface HTMLElementEventMap {
|
|
1142
|
-
[gsEventNames.
|
|
1143
|
-
[gsEventNames.dateRangeOptionChanged]: DateRangeOptionChangedEvent;
|
|
1128
|
+
[gsEventNames.locationChanged]: LocationChangedEvent;
|
|
1144
1129
|
}
|
|
1145
1130
|
}
|
|
1146
1131
|
|
|
@@ -1148,7 +1133,7 @@ declare global {
|
|
|
1148
1133
|
declare global {
|
|
1149
1134
|
namespace JSX {
|
|
1150
1135
|
interface IntrinsicElements {
|
|
1151
|
-
'gs-
|
|
1136
|
+
'gs-location-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1152
1137
|
}
|
|
1153
1138
|
}
|
|
1154
1139
|
}
|
|
@@ -1198,6 +1183,7 @@ declare global {
|
|
|
1198
1183
|
}
|
|
1199
1184
|
interface HTMLElementEventMap {
|
|
1200
1185
|
[gsEventNames.lineageFilterChanged]: LineageFilterChangedEvent;
|
|
1186
|
+
[gsEventNames.lineageFilterMultiChanged]: LineageMultiFilterChangedEvent;
|
|
1201
1187
|
}
|
|
1202
1188
|
}
|
|
1203
1189
|
|
|
@@ -1231,6 +1217,22 @@ declare global {
|
|
|
1231
1217
|
}
|
|
1232
1218
|
|
|
1233
1219
|
|
|
1220
|
+
declare global {
|
|
1221
|
+
interface HTMLElementTagNameMap {
|
|
1222
|
+
'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
declare global {
|
|
1228
|
+
namespace JSX {
|
|
1229
|
+
interface IntrinsicElements {
|
|
1230
|
+
'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
|
|
1234
1236
|
declare module 'chart.js' {
|
|
1235
1237
|
interface CartesianScaleTypeRegistry {
|
|
1236
1238
|
logit: {
|
package/dist/util.js
CHANGED
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCombobox } from 'downshift/preact';
|
|
1
|
+
import { useCombobox, useMultipleSelection } from 'downshift/preact';
|
|
2
2
|
import { type ComponentChild } from 'preact';
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
4
4
|
|
|
@@ -43,17 +43,7 @@ export function DownshiftCombobox<Item>({
|
|
|
43
43
|
divRef.current?.dispatchEvent(createEvent(item));
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const environment =
|
|
49
|
-
shadowRoot !== undefined
|
|
50
|
-
? {
|
|
51
|
-
addEventListener: window.addEventListener.bind(window),
|
|
52
|
-
removeEventListener: window.removeEventListener.bind(window),
|
|
53
|
-
document: shadowRoot.ownerDocument,
|
|
54
|
-
Node: window.Node,
|
|
55
|
-
}
|
|
56
|
-
: undefined;
|
|
46
|
+
const environment = useShadowEnvironment(divRef);
|
|
57
47
|
|
|
58
48
|
const {
|
|
59
49
|
isOpen,
|
|
@@ -120,44 +110,284 @@ export function DownshiftCombobox<Item>({
|
|
|
120
110
|
{...getInputProps()}
|
|
121
111
|
onBlur={onInputBlur}
|
|
122
112
|
/>
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
className={`px-2 ${inputValue === '' && 'hidden'}`}
|
|
126
|
-
type='button'
|
|
127
|
-
onClick={clearInput}
|
|
128
|
-
tabIndex={-1}
|
|
129
|
-
>
|
|
130
|
-
<DeleteIcon />
|
|
131
|
-
</button>
|
|
132
|
-
<button
|
|
133
|
-
aria-label='toggle menu'
|
|
134
|
-
className='px-2'
|
|
135
|
-
type='button'
|
|
136
|
-
{...getToggleButtonProps()}
|
|
137
|
-
ref={buttonRef}
|
|
138
|
-
>
|
|
139
|
-
{isOpen ? <>↑</> : <>↓</>}
|
|
140
|
-
</button>
|
|
113
|
+
<ClearButton onClick={clearInput} isHidden={inputValue === ''} />
|
|
114
|
+
<ToggleButton isOpen={isOpen} buttonRef={buttonRef} getToggleButtonProps={getToggleButtonProps} />
|
|
141
115
|
</div>
|
|
142
116
|
</div>
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
{
|
|
146
|
-
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
117
|
+
<DropdownMenu
|
|
118
|
+
isOpen={isOpen}
|
|
119
|
+
getMenuProps={getMenuProps}
|
|
120
|
+
items={items}
|
|
121
|
+
highlightedIndex={highlightedIndex}
|
|
122
|
+
getItemProps={getItemProps}
|
|
123
|
+
formatItemInList={formatItemInList}
|
|
124
|
+
itemToString={itemToString}
|
|
125
|
+
selectedItem={selectedItem}
|
|
126
|
+
emptyMessage='No elements to select.'
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function DownshiftMultiCombobox<Item>({
|
|
133
|
+
allItems,
|
|
134
|
+
value,
|
|
135
|
+
filterItemsByInputValue,
|
|
136
|
+
createEvent,
|
|
137
|
+
itemToString,
|
|
138
|
+
placeholderText,
|
|
139
|
+
formatItemInList,
|
|
140
|
+
formatSelectedItem,
|
|
141
|
+
inputClassName = '',
|
|
142
|
+
}: {
|
|
143
|
+
allItems: Item[];
|
|
144
|
+
value: Item[];
|
|
145
|
+
filterItemsByInputValue: (item: Item, value: string) => boolean;
|
|
146
|
+
createEvent: (items: Item[]) => CustomEvent;
|
|
147
|
+
itemToString: (item: Item | undefined | null) => string;
|
|
148
|
+
placeholderText?: string;
|
|
149
|
+
formatItemInList: (item: Item) => ComponentChild;
|
|
150
|
+
formatSelectedItem?: (item: Item) => ComponentChild;
|
|
151
|
+
inputClassName?: string;
|
|
152
|
+
}) {
|
|
153
|
+
const [selectedItems, setSelectedItems] = useState<Item[]>(() => value);
|
|
154
|
+
const [itemsFilter, setItemsFilter] = useState('');
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
setSelectedItems(value);
|
|
158
|
+
}, [value]);
|
|
159
|
+
|
|
160
|
+
const availableItems = useMemo(() => {
|
|
161
|
+
return allItems.filter((item) => {
|
|
162
|
+
const notAlreadySelected = !selectedItems.find(
|
|
163
|
+
(selectedItem) => itemToString(selectedItem) === itemToString(item),
|
|
164
|
+
);
|
|
165
|
+
const matchesFilter = filterItemsByInputValue(item, itemsFilter);
|
|
166
|
+
return notAlreadySelected && matchesFilter;
|
|
167
|
+
});
|
|
168
|
+
}, [allItems, selectedItems, filterItemsByInputValue, itemsFilter, itemToString]);
|
|
169
|
+
|
|
170
|
+
const divRef = useRef<HTMLDivElement>(null);
|
|
171
|
+
|
|
172
|
+
const dispatchEvent = (items: Item[]) => {
|
|
173
|
+
setSelectedItems(items);
|
|
174
|
+
divRef.current?.dispatchEvent(createEvent(items));
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const environment = useShadowEnvironment(divRef);
|
|
178
|
+
|
|
179
|
+
const { getDropdownProps, removeSelectedItem } = useMultipleSelection({
|
|
180
|
+
selectedItems,
|
|
181
|
+
onStateChange({ selectedItems: newSelectedItems, type }) {
|
|
182
|
+
switch (type) {
|
|
183
|
+
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
|
184
|
+
dispatchEvent(newSelectedItems ?? []);
|
|
185
|
+
break;
|
|
186
|
+
default:
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
environment,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const { isOpen, getToggleButtonProps, getMenuProps, getInputProps, highlightedIndex, getItemProps, closeMenu } =
|
|
194
|
+
useCombobox({
|
|
195
|
+
items: availableItems,
|
|
196
|
+
itemToString(item) {
|
|
197
|
+
return itemToString(item);
|
|
198
|
+
},
|
|
199
|
+
inputValue: itemsFilter,
|
|
200
|
+
onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) {
|
|
201
|
+
switch (type) {
|
|
202
|
+
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
|
203
|
+
case useCombobox.stateChangeTypes.ItemClick:
|
|
204
|
+
if (newSelectedItem) {
|
|
205
|
+
dispatchEvent([...selectedItems, newSelectedItem]);
|
|
206
|
+
setItemsFilter('');
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
case useCombobox.stateChangeTypes.InputChange:
|
|
210
|
+
setItemsFilter(newInputValue?.trim() ?? '');
|
|
211
|
+
break;
|
|
212
|
+
default:
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
stateReducer(state, actionAndChanges) {
|
|
217
|
+
const { changes, type } = actionAndChanges;
|
|
218
|
+
switch (type) {
|
|
219
|
+
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
|
220
|
+
case useCombobox.stateChangeTypes.ItemClick:
|
|
221
|
+
return {
|
|
222
|
+
...changes,
|
|
223
|
+
isOpen: true,
|
|
224
|
+
highlightedIndex: state.highlightedIndex,
|
|
225
|
+
inputValue: '',
|
|
226
|
+
};
|
|
227
|
+
default:
|
|
228
|
+
return changes;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
environment,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const clearAll = () => {
|
|
235
|
+
dispatchEvent([]);
|
|
236
|
+
setItemsFilter('');
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const buttonRef = useRef(null);
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div ref={divRef} className={'relative w-full'}>
|
|
243
|
+
<div className='w-full flex flex-col gap-1'>
|
|
244
|
+
<div
|
|
245
|
+
className={`flex gap-1 flex-wrap p-1.5 input min-w-24 h-fit w-full ${inputClassName}`}
|
|
246
|
+
onBlur={(event) => {
|
|
247
|
+
if (event.relatedTarget != buttonRef.current) {
|
|
248
|
+
closeMenu();
|
|
249
|
+
}
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
{selectedItems.map((selectedItem, index) => (
|
|
253
|
+
<span
|
|
254
|
+
key={`${itemToString(selectedItem)}-${index}`}
|
|
255
|
+
className='inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-black rounded'
|
|
153
256
|
>
|
|
154
|
-
{
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
257
|
+
{formatSelectedItem ? formatSelectedItem(selectedItem) : itemToString(selectedItem)}
|
|
258
|
+
<button
|
|
259
|
+
aria-label={`remove ${itemToString(selectedItem)}`}
|
|
260
|
+
className='cursor-pointer hover:text-red-600'
|
|
261
|
+
type='button'
|
|
262
|
+
onClick={() => removeSelectedItem(selectedItem)}
|
|
263
|
+
tabIndex={-1}
|
|
264
|
+
>
|
|
265
|
+
×
|
|
266
|
+
</button>
|
|
267
|
+
</span>
|
|
268
|
+
))}
|
|
269
|
+
<div className='flex gap-0.5 grow min-w-32'>
|
|
270
|
+
<input
|
|
271
|
+
placeholder={placeholderText}
|
|
272
|
+
className='w-full px-1 py-0.5 focus:outline-none min-w-24'
|
|
273
|
+
{...getInputProps(getDropdownProps({ preventKeyAction: isOpen }))}
|
|
274
|
+
/>
|
|
275
|
+
<ClearButton onClick={clearAll} isHidden={selectedItems.length === 0} />
|
|
276
|
+
<ToggleButton
|
|
277
|
+
isOpen={isOpen}
|
|
278
|
+
buttonRef={buttonRef}
|
|
279
|
+
getToggleButtonProps={getToggleButtonProps}
|
|
280
|
+
/>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
<DropdownMenu
|
|
285
|
+
isOpen={isOpen}
|
|
286
|
+
getMenuProps={getMenuProps}
|
|
287
|
+
items={availableItems}
|
|
288
|
+
highlightedIndex={highlightedIndex}
|
|
289
|
+
getItemProps={getItemProps}
|
|
290
|
+
formatItemInList={formatItemInList}
|
|
291
|
+
itemToString={itemToString}
|
|
292
|
+
selectedItem={selectedItems}
|
|
293
|
+
emptyMessage={selectedItems.length > 0 ? 'No more elements to select.' : 'No elements to select.'}
|
|
294
|
+
/>
|
|
161
295
|
</div>
|
|
162
296
|
);
|
|
163
297
|
}
|
|
298
|
+
|
|
299
|
+
function useShadowEnvironment(divRef: React.RefObject<HTMLDivElement>) {
|
|
300
|
+
const shadowRoot = divRef.current?.shadowRoot ?? undefined;
|
|
301
|
+
|
|
302
|
+
return shadowRoot !== undefined
|
|
303
|
+
? {
|
|
304
|
+
addEventListener: window.addEventListener.bind(window),
|
|
305
|
+
removeEventListener: window.removeEventListener.bind(window),
|
|
306
|
+
document: shadowRoot.ownerDocument,
|
|
307
|
+
Node: window.Node,
|
|
308
|
+
}
|
|
309
|
+
: undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function ToggleButton({
|
|
313
|
+
isOpen,
|
|
314
|
+
buttonRef,
|
|
315
|
+
getToggleButtonProps,
|
|
316
|
+
onClick,
|
|
317
|
+
}: {
|
|
318
|
+
isOpen: boolean;
|
|
319
|
+
buttonRef?: React.Ref<HTMLButtonElement>;
|
|
320
|
+
getToggleButtonProps?: () => Record<string, unknown>;
|
|
321
|
+
onClick?: () => void;
|
|
322
|
+
}) {
|
|
323
|
+
const props = getToggleButtonProps ? getToggleButtonProps() : { onClick };
|
|
324
|
+
return (
|
|
325
|
+
<button aria-label='toggle menu' className='px-2' type='button' {...props} ref={buttonRef}>
|
|
326
|
+
{isOpen ? <>↑</> : <>↓</>}
|
|
327
|
+
</button>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function ClearButton({ onClick, isHidden }: { onClick: () => void; isHidden: boolean }) {
|
|
332
|
+
return (
|
|
333
|
+
<button
|
|
334
|
+
aria-label='clear selection'
|
|
335
|
+
className={`px-2 ${isHidden ? 'hidden' : ''}`}
|
|
336
|
+
type='button'
|
|
337
|
+
onClick={onClick}
|
|
338
|
+
tabIndex={-1}
|
|
339
|
+
>
|
|
340
|
+
<DeleteIcon />
|
|
341
|
+
</button>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function DropdownMenu<Item>({
|
|
346
|
+
isOpen,
|
|
347
|
+
getMenuProps,
|
|
348
|
+
items,
|
|
349
|
+
highlightedIndex,
|
|
350
|
+
getItemProps,
|
|
351
|
+
formatItemInList,
|
|
352
|
+
itemToString,
|
|
353
|
+
selectedItem,
|
|
354
|
+
emptyMessage,
|
|
355
|
+
}: {
|
|
356
|
+
isOpen: boolean;
|
|
357
|
+
getMenuProps: () => Record<string, unknown>;
|
|
358
|
+
items: Item[];
|
|
359
|
+
highlightedIndex: number;
|
|
360
|
+
getItemProps: (options: { item: Item; index: number }) => Record<string, unknown>;
|
|
361
|
+
formatItemInList: (item: Item) => ComponentChild;
|
|
362
|
+
itemToString: (item: Item) => string;
|
|
363
|
+
selectedItem?: Item | Item[] | null;
|
|
364
|
+
emptyMessage: string;
|
|
365
|
+
}) {
|
|
366
|
+
const isItemSelected = (item: Item) => {
|
|
367
|
+
if (Array.isArray(selectedItem)) {
|
|
368
|
+
return selectedItem.some((selected) => itemToString(selected) === itemToString(item));
|
|
369
|
+
}
|
|
370
|
+
return selectedItem !== null && selectedItem !== undefined && itemToString(selectedItem) === itemToString(item);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<ul
|
|
375
|
+
className={`absolute bg-white mt-1 shadow-md max-h-80 overflow-scroll z-10 w-full min-w-32 ${isOpen ? '' : 'hidden'}`}
|
|
376
|
+
{...getMenuProps()}
|
|
377
|
+
>
|
|
378
|
+
{items.length > 0 ? (
|
|
379
|
+
items.map((item, index) => (
|
|
380
|
+
<li
|
|
381
|
+
className={`${highlightedIndex === index ? 'bg-blue-300' : ''} ${isItemSelected(item) ? 'font-bold' : ''} py-2 px-3 shadow-xs cursor-pointer`}
|
|
382
|
+
key={itemToString(item)}
|
|
383
|
+
{...getItemProps({ item, index })}
|
|
384
|
+
>
|
|
385
|
+
{formatItemInList(item)}
|
|
386
|
+
</li>
|
|
387
|
+
))
|
|
388
|
+
) : (
|
|
389
|
+
<li className='py-2 px-3 shadow-xs'>{emptyMessage}</li>
|
|
390
|
+
)}
|
|
391
|
+
</ul>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
MutationsOverTimeMutationsFilter,
|
|
8
8
|
type MutationsOverTimeMutationsFilterProps,
|
|
9
9
|
} from './mutations-over-time-mutations-filter';
|
|
10
|
+
import { type MutationAnnotations } from '../../web-components/mutation-annotations-context';
|
|
10
11
|
import { MutationAnnotationsContextProvider } from '../MutationAnnotationsContext';
|
|
11
12
|
import { type MutationFilter } from '../mutationsOverTime/getFilteredMutationsOverTimeData';
|
|
12
13
|
|
|
@@ -18,30 +19,38 @@ const meta: Meta = {
|
|
|
18
19
|
|
|
19
20
|
export default meta;
|
|
20
21
|
|
|
22
|
+
const manyMutationAnnotations = Array.from({ length: 300 }, (_, i) => ({
|
|
23
|
+
name: `Annotation ${i + 1}`,
|
|
24
|
+
description: `This is test annotation number ${i + 1} for testing many annotations.`,
|
|
25
|
+
symbol: String.fromCharCode(33 + (i % 94)), // Cycle through printable ASCII characters
|
|
26
|
+
nucleotideMutations: ['A23G'],
|
|
27
|
+
aminoAcidMutations: [],
|
|
28
|
+
})) satisfies MutationAnnotations;
|
|
29
|
+
|
|
21
30
|
const WrapperWithState = ({
|
|
22
31
|
setFilterValue,
|
|
23
32
|
value,
|
|
33
|
+
annotations = [
|
|
34
|
+
{
|
|
35
|
+
name: 'Test Annotation 1',
|
|
36
|
+
description: 'Test Annotation 1',
|
|
37
|
+
symbol: '#',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'Test Annotation 2',
|
|
41
|
+
description: 'Test Annotation 2',
|
|
42
|
+
symbol: '+',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
24
45
|
}: {
|
|
25
46
|
setFilterValue: Dispatch<StateUpdater<MutationFilter>>;
|
|
26
47
|
value: MutationFilter;
|
|
48
|
+
annotations?: MutationAnnotations;
|
|
27
49
|
}) => {
|
|
28
50
|
const [state, setState] = useState(value);
|
|
29
51
|
|
|
30
52
|
return (
|
|
31
|
-
<MutationAnnotationsContextProvider
|
|
32
|
-
value={[
|
|
33
|
-
{
|
|
34
|
-
name: 'Test Annotation 1',
|
|
35
|
-
description: 'Test Annotation 1',
|
|
36
|
-
symbol: '#',
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
name: 'Test Annotation 2',
|
|
40
|
-
description: 'Test Annotation 2',
|
|
41
|
-
symbol: '+',
|
|
42
|
-
},
|
|
43
|
-
]}
|
|
44
|
-
>
|
|
53
|
+
<MutationAnnotationsContextProvider value={annotations}>
|
|
45
54
|
<MutationsOverTimeMutationsFilter
|
|
46
55
|
setFilterValue={(value) => {
|
|
47
56
|
setFilterValue(value);
|
|
@@ -107,3 +116,53 @@ export const FilterByAnnotation: StoryObj<MutationsOverTimeMutationsFilterProps>
|
|
|
107
116
|
});
|
|
108
117
|
},
|
|
109
118
|
};
|
|
119
|
+
|
|
120
|
+
export const WithManyMutationAnnotations: StoryObj<MutationsOverTimeMutationsFilterProps> = {
|
|
121
|
+
render: (args) => {
|
|
122
|
+
return (
|
|
123
|
+
<WrapperWithState
|
|
124
|
+
setFilterValue={args.setFilterValue}
|
|
125
|
+
value={args.value}
|
|
126
|
+
annotations={manyMutationAnnotations}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
args: {
|
|
131
|
+
setFilterValue: fn(),
|
|
132
|
+
value: { textFilter: '', annotationNameFilter: new Set() },
|
|
133
|
+
},
|
|
134
|
+
play: async ({ canvasElement, step }) => {
|
|
135
|
+
const canvas = within(canvasElement);
|
|
136
|
+
|
|
137
|
+
await step('Open filter dropdown', async () => {
|
|
138
|
+
const filterButton = canvas.getByRole('button', { name: 'Filter mutations' });
|
|
139
|
+
await userEvent.click(filterButton);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await step('Verify scroll container is scrollable', () => {
|
|
143
|
+
const scrollContainer = canvas
|
|
144
|
+
.getByText('Filter by annotations')
|
|
145
|
+
.parentElement!.querySelector('.overflow-scroll')!;
|
|
146
|
+
void expect(scrollContainer).toBeInTheDocument();
|
|
147
|
+
|
|
148
|
+
// Verify the container has scrollable content
|
|
149
|
+
void expect(scrollContainer.scrollHeight).toBeGreaterThan(scrollContainer.clientHeight);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await step('Scroll to bottom and verify we can scroll', async () => {
|
|
153
|
+
const scrollContainer = canvas
|
|
154
|
+
.getByText('Filter by annotations')
|
|
155
|
+
.parentElement!.querySelector('.overflow-scroll')!;
|
|
156
|
+
|
|
157
|
+
const initialScrollTop = scrollContainer.scrollTop;
|
|
158
|
+
|
|
159
|
+
// Scroll to the bottom
|
|
160
|
+
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
161
|
+
|
|
162
|
+
await waitFor(async () => {
|
|
163
|
+
// Verify that scrollTop actually changed
|
|
164
|
+
await expect(scrollContainer.scrollTop).toBeGreaterThan(initialScrollTop);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
};
|
|
@@ -105,34 +105,36 @@ const AnnotationCheckboxes: FunctionComponent<MutationsOverTimeMutationsFilterPr
|
|
|
105
105
|
<div className='divider mt-0.5 mb-0' />
|
|
106
106
|
<div className='text-sm'>
|
|
107
107
|
<div className='font-bold mb-1'>Filter by annotations</div>
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
<
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
108
|
+
<div className='max-h-72 overflow-scroll'>
|
|
109
|
+
{mutationAnnotations.map((annotation, index) => (
|
|
110
|
+
<li className='flex flex-row items-center' key={annotation.name}>
|
|
111
|
+
<label>
|
|
112
|
+
<input
|
|
113
|
+
className={'mr-2'}
|
|
114
|
+
type='checkbox'
|
|
115
|
+
id={`item-${index}`}
|
|
116
|
+
checked={value.annotationNameFilter.has(annotation.name)}
|
|
117
|
+
onChange={() => {
|
|
118
|
+
setFilterValue((previousFilter) => {
|
|
119
|
+
const newAnnotationFilter = previousFilter.annotationNameFilter.has(
|
|
120
|
+
annotation.name,
|
|
121
|
+
)
|
|
122
|
+
? [...previousFilter.annotationNameFilter].filter(
|
|
123
|
+
(name) => name !== annotation.name,
|
|
124
|
+
)
|
|
125
|
+
: [...previousFilter.annotationNameFilter, annotation.name];
|
|
126
|
+
return {
|
|
127
|
+
...previousFilter,
|
|
128
|
+
annotationNameFilter: new Set(newAnnotationFilter),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
{annotation.name} (<span className='text-red-600'>{annotation.symbol}</span>)
|
|
134
|
+
</label>
|
|
135
|
+
</li>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
136
138
|
</div>
|
|
137
139
|
</>
|
|
138
140
|
);
|