@transferwise/components 0.0.0-experimental-91bc693 → 0.0.0-experimental-c023984
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/build/inputs/SelectInput.js +86 -34
- package/build/inputs/SelectInput.js.map +1 -1
- package/build/inputs/SelectInput.mjs +88 -36
- package/build/inputs/SelectInput.mjs.map +1 -1
- package/build/main.css +10 -0
- package/build/styles/inputs/SelectInput.css +10 -0
- package/build/styles/main.css +10 -0
- package/build/types/inputs/SelectInput.d.ts.map +1 -1
- package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
- package/build/types/uploadInput/uploadButton/UploadButton.d.ts +1 -1
- package/build/types/uploadInput/uploadButton/UploadButton.d.ts.map +1 -1
- package/build/types/uploadInput/uploadItem/UploadItem.d.ts +14 -9
- package/build/types/uploadInput/uploadItem/UploadItem.d.ts.map +1 -1
- package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts +5 -5
- package/build/types/uploadInput/uploadItem/UploadItemLink.d.ts.map +1 -1
- package/build/uploadInput/UploadInput.js +2 -25
- package/build/uploadInput/UploadInput.js.map +1 -1
- package/build/uploadInput/UploadInput.mjs +3 -26
- package/build/uploadInput/UploadInput.mjs.map +1 -1
- package/build/uploadInput/uploadButton/UploadButton.js +4 -6
- package/build/uploadInput/uploadButton/UploadButton.js.map +1 -1
- package/build/uploadInput/uploadButton/UploadButton.mjs +5 -7
- package/build/uploadInput/uploadButton/UploadButton.mjs.map +1 -1
- package/build/uploadInput/uploadItem/UploadItem.js +30 -22
- package/build/uploadInput/uploadItem/UploadItem.js.map +1 -1
- package/build/uploadInput/uploadItem/UploadItem.mjs +28 -22
- package/build/uploadInput/uploadItem/UploadItem.mjs.map +1 -1
- package/build/uploadInput/uploadItem/UploadItemLink.js +3 -5
- package/build/uploadInput/uploadItem/UploadItemLink.js.map +1 -1
- package/build/uploadInput/uploadItem/UploadItemLink.mjs +3 -5
- package/build/uploadInput/uploadItem/UploadItemLink.mjs.map +1 -1
- package/package.json +4 -3
- package/src/inputs/SelectInput.css +10 -0
- package/src/inputs/SelectInput.less +12 -0
- package/src/inputs/SelectInput.story.tsx +20 -0
- package/src/inputs/SelectInput.tsx +116 -37
- package/src/main.css +10 -0
- package/src/uploadInput/UploadInput.spec.tsx +3 -4
- package/src/uploadInput/UploadInput.story.tsx +2 -2
- package/src/uploadInput/UploadInput.tsx +2 -28
- package/src/uploadInput/uploadButton/UploadButton.tsx +151 -146
- package/src/uploadInput/uploadItem/UploadItem.tsx +141 -122
- package/src/uploadInput/uploadItem/UploadItemLink.tsx +25 -23
|
@@ -322,6 +322,26 @@ export const Advanced: Story<Month> = {
|
|
|
322
322
|
},
|
|
323
323
|
};
|
|
324
324
|
|
|
325
|
+
export const ManyItems: Story<string, true> = {
|
|
326
|
+
args: {
|
|
327
|
+
multiple: true,
|
|
328
|
+
items: Array.from({ length: 1000 }, (_, index) => ({
|
|
329
|
+
type: 'option',
|
|
330
|
+
value: String(index + 1),
|
|
331
|
+
})),
|
|
332
|
+
renderValue: (value, withinTrigger) =>
|
|
333
|
+
withinTrigger ? (
|
|
334
|
+
value
|
|
335
|
+
) : (
|
|
336
|
+
<SelectInputOptionContent
|
|
337
|
+
title={value}
|
|
338
|
+
description={Number(value) % 10 === 0 ? 'Divisible by 10' : undefined}
|
|
339
|
+
/>
|
|
340
|
+
),
|
|
341
|
+
filterable: true,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
|
|
325
345
|
export const WithinDrawer: Story<Currency> = {
|
|
326
346
|
args: CurrenciesArgs,
|
|
327
347
|
decorators: [
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createContext,
|
|
7
7
|
forwardRef,
|
|
8
8
|
useContext,
|
|
9
|
+
useDeferredValue,
|
|
9
10
|
useEffect,
|
|
10
11
|
useId,
|
|
11
12
|
useMemo,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
useState,
|
|
14
15
|
} from 'react';
|
|
15
16
|
import { useIntl } from 'react-intl';
|
|
17
|
+
import { Virtualizer } from 'virtua';
|
|
16
18
|
|
|
17
19
|
import { useEffectEvent } from '../common/hooks/useEffectEvent';
|
|
18
20
|
import { useScreenSize } from '../common/hooks/useScreenSize';
|
|
@@ -29,6 +31,8 @@ import { InputGroup } from './InputGroup';
|
|
|
29
31
|
import { SearchInput } from './SearchInput';
|
|
30
32
|
import messages from './SelectInput.messages';
|
|
31
33
|
|
|
34
|
+
const MAX_ITEMS_WITHOUT_VIRTUALIZATION = 50;
|
|
35
|
+
|
|
32
36
|
function searchableString(value: string) {
|
|
33
37
|
return value.trim().replace(/\s+/gu, ' ').normalize('NFKC').toLowerCase();
|
|
34
38
|
}
|
|
@@ -40,7 +44,7 @@ function inferSearchableStrings(value: unknown) {
|
|
|
40
44
|
|
|
41
45
|
if (typeof value === 'object' && value != null) {
|
|
42
46
|
return Object.values(value)
|
|
43
|
-
.filter((innerValue)
|
|
47
|
+
.filter((innerValue) => typeof innerValue === 'string')
|
|
44
48
|
.map((innerValue) => searchableString(innerValue));
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -89,6 +93,11 @@ function dedupeSelectInputOptionItem<T>(
|
|
|
89
93
|
return { ...item, value: undefined };
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Sets the `value` of duplicate option items to `undefined`, hiding them when
|
|
98
|
+
* rendered. Indexes are kept intact within groups to preserve the active item
|
|
99
|
+
* between filter changes when possible.
|
|
100
|
+
*/
|
|
92
101
|
function dedupeSelectInputItems<T>(
|
|
93
102
|
items: readonly SelectInputItem<T>[],
|
|
94
103
|
): SelectInputItem<T | undefined>[] {
|
|
@@ -112,20 +121,23 @@ function dedupeSelectInputItems<T>(
|
|
|
112
121
|
});
|
|
113
122
|
}
|
|
114
123
|
|
|
115
|
-
function
|
|
124
|
+
function selectInputOptionItemIncludesNeedle<T>(item: SelectInputOptionItem<T>, needle: string) {
|
|
116
125
|
return inferSearchableStrings(item.filterMatchers ?? item.value).some((haystack) =>
|
|
117
126
|
haystack.includes(needle),
|
|
118
127
|
);
|
|
119
128
|
}
|
|
120
129
|
|
|
121
|
-
function filterSelectInputItems<T>(
|
|
130
|
+
function filterSelectInputItems<T>(
|
|
131
|
+
items: readonly SelectInputItem<T>[],
|
|
132
|
+
predicate: (item: SelectInputOptionItem<T>) => boolean,
|
|
133
|
+
) {
|
|
122
134
|
return items.filter((item) => {
|
|
123
135
|
switch (item.type) {
|
|
124
136
|
case 'option': {
|
|
125
|
-
return
|
|
137
|
+
return predicate(item);
|
|
126
138
|
}
|
|
127
139
|
case 'group': {
|
|
128
|
-
return item.options.some((option) =>
|
|
140
|
+
return item.options.some((option) => predicate(option));
|
|
129
141
|
}
|
|
130
142
|
default:
|
|
131
143
|
}
|
|
@@ -271,12 +283,15 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
271
283
|
}, [handleClose, open]);
|
|
272
284
|
|
|
273
285
|
const [filterQuery, _setFilterQuery] = useState('');
|
|
286
|
+
const deferredFilterQuery = useDeferredValue(filterQuery);
|
|
274
287
|
const setFilterQuery = useEffectEvent((query: string) => {
|
|
275
288
|
_setFilterQuery(query);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
289
|
+
if (query !== filterQuery) {
|
|
290
|
+
onFilterChange({
|
|
291
|
+
query,
|
|
292
|
+
queryNormalized: query ? searchableString(query) : null,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
280
295
|
});
|
|
281
296
|
|
|
282
297
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
@@ -294,9 +309,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
294
309
|
multiple={multiple}
|
|
295
310
|
defaultValue={defaultValue}
|
|
296
311
|
value={controlledValue}
|
|
297
|
-
|
|
298
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
299
|
-
by={compareValues as any}
|
|
312
|
+
by={compareValues}
|
|
300
313
|
disabled={disabled}
|
|
301
314
|
onChange={
|
|
302
315
|
((value) => {
|
|
@@ -349,8 +362,8 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
349
362
|
content: !placeholderShown ? (
|
|
350
363
|
<SelectInputOptionContentWithinTriggerContext.Provider value>
|
|
351
364
|
{multiple && Array.isArray(value)
|
|
352
|
-
? value
|
|
353
|
-
.map((option
|
|
365
|
+
? (value as readonly NonNullable<T>[])
|
|
366
|
+
.map((option) => renderValue(option, true))
|
|
354
367
|
.filter((node) => node != null)
|
|
355
368
|
.join(', ')
|
|
356
369
|
: renderValue(value as NonNullable<T>, true)}
|
|
@@ -379,9 +392,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
379
392
|
setOpen(false);
|
|
380
393
|
}}
|
|
381
394
|
onCloseEnd={() => {
|
|
382
|
-
|
|
383
|
-
setFilterQuery('');
|
|
384
|
-
}
|
|
395
|
+
setFilterQuery('');
|
|
385
396
|
}}
|
|
386
397
|
>
|
|
387
398
|
<SelectInputOptions
|
|
@@ -392,7 +403,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
392
403
|
filterPlaceholder={filterPlaceholder}
|
|
393
404
|
searchInputRef={searchInputRef}
|
|
394
405
|
listboxRef={listboxRef}
|
|
395
|
-
filterQuery={
|
|
406
|
+
filterQuery={deferredFilterQuery}
|
|
396
407
|
onFilterChange={setFilterQuery}
|
|
397
408
|
/>
|
|
398
409
|
</OptionsOverlay>
|
|
@@ -506,7 +517,48 @@ function SelectInputOptions<T = string>({
|
|
|
506
517
|
}
|
|
507
518
|
return undefined;
|
|
508
519
|
}, [filterQuery, filterable]);
|
|
509
|
-
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
if (needle) {
|
|
522
|
+
// Ensure having an active option while filtering
|
|
523
|
+
requestAnimationFrame(() => {
|
|
524
|
+
if (
|
|
525
|
+
controllerRef.current != null &&
|
|
526
|
+
!controllerRef.current.hasAttribute('aria-activedescendant')
|
|
527
|
+
) {
|
|
528
|
+
// Activate first option via synthetic key press
|
|
529
|
+
controllerRef.current.dispatchEvent(
|
|
530
|
+
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}, [controllerRef, needle]);
|
|
536
|
+
|
|
537
|
+
const filteredItems: readonly SelectInputItem<NonNullable<T> | undefined>[] =
|
|
538
|
+
needle != null
|
|
539
|
+
? filterSelectInputItems(dedupeSelectInputItems(items), (item) =>
|
|
540
|
+
selectInputOptionItemIncludesNeedle(item, needle),
|
|
541
|
+
)
|
|
542
|
+
: items;
|
|
543
|
+
const resultsEmpty = needle != null && filteredItems.length === 0;
|
|
544
|
+
|
|
545
|
+
const virtualized = filteredItems.length > MAX_ITEMS_WITHOUT_VIRTUALIZATION;
|
|
546
|
+
|
|
547
|
+
// Items shown once shall be kept mounted until the needle changes, otherwise
|
|
548
|
+
// the scroll position may jump around inadvertently. Pattern adopted from:
|
|
549
|
+
// https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only
|
|
550
|
+
const [mountedIndexes, setMountedIndexes] = useState<number[]>([]);
|
|
551
|
+
useEffect(() => {
|
|
552
|
+
// Ensure the 'End' key works as intended by keeping the last item mounted
|
|
553
|
+
setMountedIndexes((prevMountedIndexes) => {
|
|
554
|
+
const indexes = new Set(prevMountedIndexes);
|
|
555
|
+
indexes.add(filteredItems.length - 1);
|
|
556
|
+
return [...indexes]; // Sorting is redundant by nature here
|
|
557
|
+
});
|
|
558
|
+
}, [
|
|
559
|
+
needle, // Needed as `filteredItems.length` may be equal between two updates
|
|
560
|
+
filteredItems.length,
|
|
561
|
+
]);
|
|
510
562
|
|
|
511
563
|
const listboxContainerRef = useRef<HTMLDivElement>(null);
|
|
512
564
|
useEffect(() => {
|
|
@@ -522,6 +574,19 @@ function SelectInputOptions<T = string>({
|
|
|
522
574
|
const statusId = useId();
|
|
523
575
|
const listboxId = useId();
|
|
524
576
|
|
|
577
|
+
const getItemNode = (index: number) => {
|
|
578
|
+
const item = filteredItems[index];
|
|
579
|
+
return (
|
|
580
|
+
<SelectInputItemView
|
|
581
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
582
|
+
key={index}
|
|
583
|
+
item={item}
|
|
584
|
+
renderValue={renderValue}
|
|
585
|
+
needle={needle}
|
|
586
|
+
/>
|
|
587
|
+
);
|
|
588
|
+
};
|
|
589
|
+
|
|
525
590
|
return (
|
|
526
591
|
<ListboxBase.Options
|
|
527
592
|
as={SelectInputOptionsContainer}
|
|
@@ -533,12 +598,6 @@ function SelectInputOptions<T = string>({
|
|
|
533
598
|
controllerRef.current.setAttribute('aria-activedescendant', value);
|
|
534
599
|
} else {
|
|
535
600
|
controllerRef.current.removeAttribute('aria-activedescendant');
|
|
536
|
-
if (filterQuery) {
|
|
537
|
-
// Ensure having an active option while filtering
|
|
538
|
-
controllerRef.current.dispatchEvent(
|
|
539
|
-
new KeyboardEvent('keydown', { key: 'Home', bubbles: true }),
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
601
|
}
|
|
543
602
|
}
|
|
544
603
|
}}
|
|
@@ -549,7 +608,7 @@ function SelectInputOptions<T = string>({
|
|
|
549
608
|
ref={searchInputRef}
|
|
550
609
|
shape="rectangle"
|
|
551
610
|
placeholder={filterPlaceholder}
|
|
552
|
-
|
|
611
|
+
defaultValue={filterQuery}
|
|
553
612
|
aria-controls={listboxId}
|
|
554
613
|
aria-describedby={showStatus ? statusId : undefined}
|
|
555
614
|
onKeyDown={(event) => {
|
|
@@ -560,6 +619,9 @@ function SelectInputOptions<T = string>({
|
|
|
560
619
|
}
|
|
561
620
|
}}
|
|
562
621
|
onChange={(event) => {
|
|
622
|
+
// Free up resources and ensure not to go out of bounds when the
|
|
623
|
+
// resulting item count is less than before
|
|
624
|
+
setMountedIndexes([]);
|
|
563
625
|
onFilterChange(event.currentTarget.value);
|
|
564
626
|
}}
|
|
565
627
|
/>
|
|
@@ -571,7 +633,9 @@ function SelectInputOptions<T = string>({
|
|
|
571
633
|
tabIndex={-1}
|
|
572
634
|
className={classNames(
|
|
573
635
|
'np-select-input-listbox-container',
|
|
574
|
-
|
|
636
|
+
virtualized && 'np-select-input-listbox-container--virtualized',
|
|
637
|
+
needle == null && // Groups aren't shown when filtering
|
|
638
|
+
items.some((item) => item.type === 'group') &&
|
|
575
639
|
'np-select-input-listbox-container--has-group',
|
|
576
640
|
)}
|
|
577
641
|
>
|
|
@@ -590,15 +654,27 @@ function SelectInputOptions<T = string>({
|
|
|
590
654
|
tabIndex={0}
|
|
591
655
|
className="np-select-input-listbox"
|
|
592
656
|
>
|
|
593
|
-
{
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
657
|
+
{!virtualized ? (
|
|
658
|
+
filteredItems.map((_, index) => getItemNode(index))
|
|
659
|
+
) : (
|
|
660
|
+
<Virtualizer
|
|
661
|
+
key={needle}
|
|
662
|
+
count={filteredItems.length}
|
|
663
|
+
keepMounted={mountedIndexes}
|
|
664
|
+
scrollRef={listboxRef} // `VList` doesn't expose this
|
|
665
|
+
onRangeChange={(startIndex, endIndex) => {
|
|
666
|
+
setMountedIndexes((prevMountedIndexes) => {
|
|
667
|
+
const indexes = new Set(prevMountedIndexes);
|
|
668
|
+
for (let index = startIndex; index <= endIndex; index += 1) {
|
|
669
|
+
indexes.add(index);
|
|
670
|
+
}
|
|
671
|
+
return [...indexes].sort((a, b) => a - b);
|
|
672
|
+
});
|
|
673
|
+
}}
|
|
674
|
+
>
|
|
675
|
+
{(index) => getItemNode(index)}
|
|
676
|
+
</Virtualizer>
|
|
677
|
+
)}
|
|
602
678
|
</div>
|
|
603
679
|
|
|
604
680
|
{renderFooter != null ? (
|
|
@@ -639,7 +715,10 @@ function SelectInputItemView<T = string>({
|
|
|
639
715
|
}: SelectInputItemViewProps<T>) {
|
|
640
716
|
switch (item.type) {
|
|
641
717
|
case 'option': {
|
|
642
|
-
if (
|
|
718
|
+
if (
|
|
719
|
+
item.value != null &&
|
|
720
|
+
(needle == null || selectInputOptionItemIncludesNeedle(item, needle))
|
|
721
|
+
) {
|
|
643
722
|
return (
|
|
644
723
|
<SelectInputOption value={item.value} disabled={item.disabled}>
|
|
645
724
|
{renderValue(item.value, false)}
|
package/src/main.css
CHANGED
|
@@ -2655,6 +2655,10 @@ html:not([dir="rtl"]) .np-flow-navigation--sm .np-flow-navigation__stepper {
|
|
|
2655
2655
|
height: auto;
|
|
2656
2656
|
}
|
|
2657
2657
|
}
|
|
2658
|
+
.np-select-input-listbox-container--virtualized {
|
|
2659
|
+
/* The wrapping element shrinks this as needed */
|
|
2660
|
+
height: 100vh;
|
|
2661
|
+
}
|
|
2658
2662
|
.np-select-input-listbox-container--has-group {
|
|
2659
2663
|
scroll-padding-top: 32px;
|
|
2660
2664
|
scroll-padding-top: var(--size-32);
|
|
@@ -2673,6 +2677,12 @@ html:not([dir="rtl"]) .np-flow-navigation--sm .np-flow-navigation__stepper {
|
|
|
2673
2677
|
outline: var(--ring-outline-color) solid var(--ring-outline-width);
|
|
2674
2678
|
outline-offset: var(--ring-outline-offset);
|
|
2675
2679
|
}
|
|
2680
|
+
.np-select-input-listbox-container--virtualized .np-select-input-listbox {
|
|
2681
|
+
/* Adopted from `VList` in virtua: https://github.com/inokawa/virtua/blob/7f6ed5b37df6b480d4ff350f3960067c5b3519d2/src/react/VList.tsx#L113-L116 */
|
|
2682
|
+
overflow-y: auto;
|
|
2683
|
+
contain: strict;
|
|
2684
|
+
height: 100%;
|
|
2685
|
+
}
|
|
2676
2686
|
.np-select-input-separator-item {
|
|
2677
2687
|
margin: 8px;
|
|
2678
2688
|
margin: var(--size-8);
|
|
@@ -8,6 +8,7 @@ import { mockMatchMedia, render, screen, waitFor, waitForElementToBeRemoved } fr
|
|
|
8
8
|
|
|
9
9
|
import UploadInput, { UploadInputProps } from './UploadInput';
|
|
10
10
|
import { TEST_IDS as UPLOAD_BUTTON_TEST_IDS } from './uploadButton/UploadButton';
|
|
11
|
+
import { TEST_IDS as UPLOAD_ITEM_TEST_IDS } from './uploadItem/UploadItem';
|
|
11
12
|
|
|
12
13
|
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
|
|
13
14
|
|
|
@@ -138,8 +139,7 @@ describe('UploadInput', () => {
|
|
|
138
139
|
onFilesChange,
|
|
139
140
|
});
|
|
140
141
|
|
|
141
|
-
const
|
|
142
|
-
const fileToDelete = screen.getByTestId(fileToDeleteTestId);
|
|
142
|
+
const fileToDelete = screen.getAllByTestId(UPLOAD_ITEM_TEST_IDS.uploadItem)[0];
|
|
143
143
|
within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
|
|
144
144
|
await act(async () => {
|
|
145
145
|
await jest.runOnlyPendingTimersAsync();
|
|
@@ -190,8 +190,7 @@ describe('UploadInput', () => {
|
|
|
190
190
|
onFilesChange,
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
-
const
|
|
194
|
-
const fileToDelete = screen.getByTestId(fileToDeleteTestId);
|
|
193
|
+
const fileToDelete = screen.getAllByTestId(UPLOAD_ITEM_TEST_IDS.uploadItem)[0];
|
|
195
194
|
within(fileToDelete).getByLabelText('Remove file', { exact: false }).click();
|
|
196
195
|
await act(async () => {
|
|
197
196
|
await jest.runOnlyPendingTimersAsync();
|
|
@@ -128,9 +128,6 @@ const UploadInput = ({
|
|
|
128
128
|
sizeLimitErrorMessage,
|
|
129
129
|
uploadButtonTitle,
|
|
130
130
|
}: UploadInputProps) => {
|
|
131
|
-
const uploadButtonRef = useRef<HTMLInputElement | null>(null);
|
|
132
|
-
const uploadItemRefs = useRef<(HTMLButtonElement | HTMLAnchorElement | null)[]>([]);
|
|
133
|
-
|
|
134
131
|
const inputAttributes = useInputAttributes({ nonLabelable: true });
|
|
135
132
|
|
|
136
133
|
const [markedFileForDelete, setMarkedFileForDelete] = useState<UploadedFile | null>(null);
|
|
@@ -183,37 +180,19 @@ const UploadInput = ({
|
|
|
183
180
|
if (status === Status.FAILED) {
|
|
184
181
|
// If removing a failed upload, we're just updating the view
|
|
185
182
|
removeFileFromList(file);
|
|
186
|
-
// Move focus management logic here for failed status
|
|
187
|
-
manageFocusAfterRemoval(file);
|
|
188
183
|
} else if (onDeleteFile && id) {
|
|
189
184
|
// Set status to PROCESSING
|
|
190
185
|
modifyFileInList(file, { status: Status.PROCESSING, error: undefined });
|
|
191
186
|
|
|
192
187
|
// Notify host app about deletion
|
|
193
188
|
onDeleteFile(id)
|
|
194
|
-
.then(() =>
|
|
195
|
-
removeFileFromList(file);
|
|
196
|
-
// Move focus management logic here for successful deletion
|
|
197
|
-
manageFocusAfterRemoval(file);
|
|
198
|
-
})
|
|
189
|
+
.then(() => removeFileFromList(file))
|
|
199
190
|
.catch((error) => {
|
|
200
191
|
modifyFileInList(file, { error: error as UploadError });
|
|
201
192
|
});
|
|
202
193
|
}
|
|
203
194
|
};
|
|
204
195
|
|
|
205
|
-
// Extracted focus management logic into a separate function
|
|
206
|
-
const manageFocusAfterRemoval = (file: UploadedFile) => {
|
|
207
|
-
const index = uploadedFiles.findIndex((f) => f.id === file.id);
|
|
208
|
-
const nextFocusIndex = index >= uploadedFiles.length - 1 ? index - 1 : index + 1;
|
|
209
|
-
|
|
210
|
-
if (nextFocusIndex >= 0 && uploadItemRefs.current[nextFocusIndex]) {
|
|
211
|
-
uploadItemRefs.current[nextFocusIndex].focus();
|
|
212
|
-
} else if (uploadButtonRef.current) {
|
|
213
|
-
uploadButtonRef.current.focus();
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
|
|
217
196
|
function handleFileUploadFailure(file: File, failureMessage: string) {
|
|
218
197
|
const { name } = file;
|
|
219
198
|
const id = generateFileId(file);
|
|
@@ -328,13 +307,9 @@ const UploadInput = ({
|
|
|
328
307
|
className={classNames('np-upload-input', className, { disabled })}
|
|
329
308
|
{...inputAttributes}
|
|
330
309
|
>
|
|
331
|
-
{uploadedFiles.map((file
|
|
310
|
+
{uploadedFiles.map((file) => (
|
|
332
311
|
<UploadItem
|
|
333
|
-
ref={(el) => {
|
|
334
|
-
uploadItemRefs.current[index] = el;
|
|
335
|
-
}}
|
|
336
312
|
key={file.id}
|
|
337
|
-
testid={String(file.id)}
|
|
338
313
|
file={file}
|
|
339
314
|
singleFileUpload={!multiple}
|
|
340
315
|
canDelete={
|
|
@@ -351,7 +326,6 @@ const UploadInput = ({
|
|
|
351
326
|
))}
|
|
352
327
|
{(multiple || (!multiple && !uploadedFiles.length)) && (
|
|
353
328
|
<UploadButton
|
|
354
|
-
ref={uploadButtonRef}
|
|
355
329
|
id={id}
|
|
356
330
|
uploadButtonTitle={uploadButtonTitle}
|
|
357
331
|
disabled={areMaximumFilesUploadedAlready() || disabled}
|