@transferwise/components 46.140.1 → 46.141.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/build/avatarWrapper/AvatarWrapper.js +3 -4
- package/build/avatarWrapper/AvatarWrapper.js.map +1 -1
- package/build/avatarWrapper/AvatarWrapper.mjs +4 -5
- package/build/avatarWrapper/AvatarWrapper.mjs.map +1 -1
- package/build/button/LegacyButton.js.map +1 -1
- package/build/button/LegacyButton.mjs.map +1 -1
- package/build/common/hooks/useHasIntersected/useHasIntersected.js +6 -4
- package/build/common/hooks/useHasIntersected/useHasIntersected.js.map +1 -1
- package/build/common/hooks/useHasIntersected/useHasIntersected.mjs +6 -4
- package/build/common/hooks/useHasIntersected/useHasIntersected.mjs.map +1 -1
- package/build/common/liveRegion/LiveRegion.js +4 -1
- package/build/common/liveRegion/LiveRegion.js.map +1 -1
- package/build/common/liveRegion/LiveRegion.mjs +4 -1
- package/build/common/liveRegion/LiveRegion.mjs.map +1 -1
- package/build/dateInput/DateInput.js +10 -10
- package/build/dateInput/DateInput.js.map +1 -1
- package/build/dateInput/DateInput.mjs +10 -10
- package/build/dateInput/DateInput.mjs.map +1 -1
- package/build/dateLookup/monthCalendar/table/MonthCalendarTable.js +1 -1
- package/build/dateLookup/monthCalendar/table/MonthCalendarTable.js.map +1 -1
- package/build/dateLookup/monthCalendar/table/MonthCalendarTable.mjs +1 -1
- package/build/dateLookup/monthCalendar/table/MonthCalendarTable.mjs.map +1 -1
- package/build/dateLookup/yearCalendar/table/YearCalendarTable.js +1 -1
- package/build/dateLookup/yearCalendar/table/YearCalendarTable.js.map +1 -1
- package/build/dateLookup/yearCalendar/table/YearCalendarTable.mjs +1 -1
- package/build/dateLookup/yearCalendar/table/YearCalendarTable.mjs.map +1 -1
- package/build/expressiveMoneyInput/ExpressiveMoneyInput.js.map +1 -1
- package/build/expressiveMoneyInput/ExpressiveMoneyInput.mjs.map +1 -1
- package/build/expressiveMoneyInput/amountInput/AmountInput.js +17 -11
- package/build/expressiveMoneyInput/amountInput/AmountInput.js.map +1 -1
- package/build/expressiveMoneyInput/amountInput/AmountInput.mjs +18 -12
- package/build/expressiveMoneyInput/amountInput/AmountInput.mjs.map +1 -1
- package/build/expressiveMoneyInput/hooks/useInputStyle.js +8 -6
- package/build/expressiveMoneyInput/hooks/useInputStyle.js.map +1 -1
- package/build/expressiveMoneyInput/hooks/useInputStyle.mjs +9 -7
- package/build/expressiveMoneyInput/hooks/useInputStyle.mjs.map +1 -1
- package/build/header/Header.js +1 -1
- package/build/header/Header.js.map +1 -1
- package/build/header/Header.mjs +1 -1
- package/build/header/Header.mjs.map +1 -1
- package/build/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.js.map +1 -1
- package/build/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.mjs.map +1 -1
- package/build/inputs/SelectInput/Options/SelectInputOptions.js +34 -22
- package/build/inputs/SelectInput/Options/SelectInputOptions.js.map +1 -1
- package/build/inputs/SelectInput/Options/SelectInputOptions.mjs +35 -23
- package/build/inputs/SelectInput/Options/SelectInputOptions.mjs.map +1 -1
- package/build/inputs/SelectInput/Popover/SelectInputPopover.js.map +1 -1
- package/build/inputs/SelectInput/Popover/SelectInputPopover.mjs.map +1 -1
- package/build/inputs/SelectInput/SelectInput.js +8 -6
- package/build/inputs/SelectInput/SelectInput.js.map +1 -1
- package/build/inputs/SelectInput/SelectInput.mjs +9 -7
- package/build/inputs/SelectInput/SelectInput.mjs.map +1 -1
- package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.js.map +1 -1
- package/build/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.mjs.map +1 -1
- package/build/nudge/Nudge.js +31 -15
- package/build/nudge/Nudge.js.map +1 -1
- package/build/nudge/Nudge.mjs +32 -16
- package/build/nudge/Nudge.mjs.map +1 -1
- package/build/phoneNumberInput/PhoneNumberInput.js +9 -12
- package/build/phoneNumberInput/PhoneNumberInput.js.map +1 -1
- package/build/phoneNumberInput/PhoneNumberInput.mjs +9 -12
- package/build/phoneNumberInput/PhoneNumberInput.mjs.map +1 -1
- package/build/promoCard/PromoCardGroup.js +34 -16
- package/build/promoCard/PromoCardGroup.js.map +1 -1
- package/build/promoCard/PromoCardGroup.mjs +35 -17
- package/build/promoCard/PromoCardGroup.mjs.map +1 -1
- package/build/segmentedControl/SegmentedControl.js +6 -1
- package/build/segmentedControl/SegmentedControl.js.map +1 -1
- package/build/segmentedControl/SegmentedControl.mjs +7 -2
- package/build/segmentedControl/SegmentedControl.mjs.map +1 -1
- package/build/tabs/Tabs.js +1 -1
- package/build/tabs/Tabs.js.map +1 -1
- package/build/tabs/Tabs.mjs +1 -1
- package/build/tabs/Tabs.mjs.map +1 -1
- package/build/tooltip/Tooltip.js +6 -3
- package/build/tooltip/Tooltip.js.map +1 -1
- package/build/tooltip/Tooltip.mjs +6 -3
- package/build/tooltip/Tooltip.mjs.map +1 -1
- package/build/types/avatarWrapper/AvatarWrapper.d.ts.map +1 -1
- package/build/types/common/hooks/useHasIntersected/useHasIntersected.d.ts.map +1 -1
- package/build/types/common/liveRegion/LiveRegion.d.ts.map +1 -1
- package/build/types/dateLookup/monthCalendar/table/MonthCalendarTable.d.ts.map +1 -1
- package/build/types/expressiveMoneyInput/ExpressiveMoneyInput.d.ts.map +1 -1
- package/build/types/expressiveMoneyInput/amountInput/AmountInput.d.ts.map +1 -1
- package/build/types/expressiveMoneyInput/hooks/useInputStyle.d.ts +2 -2
- package/build/types/expressiveMoneyInput/hooks/useInputStyle.d.ts.map +1 -1
- package/build/types/expressiveMoneyInput/hooks/useSelectionRange.d.ts.map +1 -1
- package/build/types/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.d.ts.map +1 -1
- package/build/types/inputs/SelectInput/Options/SelectInputOptions.d.ts.map +1 -1
- package/build/types/inputs/SelectInput/Popover/SelectInputPopover.d.ts.map +1 -1
- package/build/types/inputs/SelectInput/SelectInput.d.ts.map +1 -1
- package/build/types/nudge/Nudge.d.ts.map +1 -1
- package/build/types/phoneNumberInput/PhoneNumberInput.d.ts.map +1 -1
- package/build/types/promoCard/PromoCardGroup.d.ts.map +1 -1
- package/build/types/segmentedControl/SegmentedControl.d.ts.map +1 -1
- package/build/types/tooltip/Tooltip.d.ts.map +1 -1
- package/build/types/uploadInput/UploadInput.d.ts.map +1 -1
- package/build/uploadInput/UploadInput.js +29 -25
- package/build/uploadInput/UploadInput.js.map +1 -1
- package/build/uploadInput/UploadInput.mjs +29 -25
- package/build/uploadInput/UploadInput.mjs.map +1 -1
- package/package.json +2 -2
- package/src/avatarWrapper/AvatarWrapper.test.tsx +33 -3
- package/src/avatarWrapper/AvatarWrapper.tsx +5 -6
- package/src/button/LegacyButton.tsx +1 -1
- package/src/button/_stories/Button.test.story.tsx +3 -3
- package/src/common/hooks/useContainerSize.test.tsx +1 -1
- package/src/common/hooks/useHasIntersected/useHasIntersected.ts +12 -4
- package/src/common/liveRegion/LiveRegion.tsx +5 -2
- package/src/dateInput/DateInput.tsx +10 -10
- package/src/dateLookup/monthCalendar/table/MonthCalendarTable.tsx +1 -5
- package/src/dateLookup/yearCalendar/table/YearCalendarTable.tsx +1 -1
- package/src/expressiveMoneyInput/ExpressiveMoneyInput.tsx +1 -1
- package/src/expressiveMoneyInput/amountInput/AmountInput.tsx +22 -15
- package/src/expressiveMoneyInput/hooks/useInputStyle.ts +20 -8
- package/src/expressiveMoneyInput/hooks/useSelectionRange.ts +2 -0
- package/src/header/Header.tsx +2 -2
- package/src/inputs/SelectInput/BottomSheet/SelectInputBottomSheet.tsx +4 -0
- package/src/inputs/SelectInput/Options/SelectInputOptions.tsx +43 -27
- package/src/inputs/SelectInput/Popover/SelectInputPopover.tsx +4 -0
- package/src/inputs/SelectInput/SelectInput.tsx +21 -15
- package/src/inputs/SelectInput/TriggerButton/SelectInputTriggerButton.tsx +1 -1
- package/src/nudge/Nudge.tsx +29 -20
- package/src/phoneNumberInput/PhoneNumberInput.test.tsx +16 -0
- package/src/phoneNumberInput/PhoneNumberInput.tsx +11 -13
- package/src/promoCard/PromoCard.story.tsx +3 -3
- package/src/promoCard/PromoCardGroup.tsx +39 -21
- package/src/segmentedControl/SegmentedControl.test.tsx +25 -0
- package/src/segmentedControl/SegmentedControl.tsx +7 -1
- package/src/select/Select.story.tsx +1 -1
- package/src/tabs/Tabs.tsx +1 -1
- package/src/tooltip/Tooltip.tsx +3 -0
- package/src/uploadInput/UploadInput.test.tsx +19 -0
- package/src/uploadInput/UploadInput.tsx +28 -24
|
@@ -42,7 +42,7 @@ export const AmountInput = ({
|
|
|
42
42
|
const intl = useIntl();
|
|
43
43
|
const { focus, setFocus, visualFocus, setVisualFocus } = useFocus();
|
|
44
44
|
|
|
45
|
-
const [value, setValue] = useState<string>(
|
|
45
|
+
const [value, setValue] = useState<string>(() =>
|
|
46
46
|
amount
|
|
47
47
|
? getFormattedString({
|
|
48
48
|
value: amount,
|
|
@@ -51,6 +51,7 @@ export const AmountInput = ({
|
|
|
51
51
|
})
|
|
52
52
|
: '',
|
|
53
53
|
);
|
|
54
|
+
const prevAmountRef = useRef<string | number | null | undefined>(amount);
|
|
54
55
|
const numericValue = useMemo(() => {
|
|
55
56
|
return getUnformattedNumber({
|
|
56
57
|
value,
|
|
@@ -85,23 +86,28 @@ export const AmountInput = ({
|
|
|
85
86
|
const decimalMode = decimalSeparator && value.includes(decimalSeparator);
|
|
86
87
|
|
|
87
88
|
useEffect(() => {
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
if (prevAmountRef.current !== amount) {
|
|
90
|
+
prevAmountRef.current = amount;
|
|
91
|
+
|
|
92
|
+
// Only update the displayed value if not focused (preserve user input when focused)
|
|
93
|
+
if (!focus) {
|
|
94
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing external prop to internal state when unfocused
|
|
95
|
+
setValue(
|
|
96
|
+
amount
|
|
97
|
+
? getFormattedString({
|
|
98
|
+
value: amount,
|
|
99
|
+
currency,
|
|
100
|
+
locale: intl.locale,
|
|
101
|
+
})
|
|
102
|
+
: '',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
98
105
|
}
|
|
99
|
-
|
|
100
|
-
}, [amount]);
|
|
106
|
+
}, [amount, focus, currency, intl.locale]);
|
|
101
107
|
|
|
102
108
|
useEffect(() => {
|
|
103
109
|
onFocusChange?.(visualFocus);
|
|
104
|
-
}, [visualFocus]);
|
|
110
|
+
}, [onFocusChange, visualFocus]);
|
|
105
111
|
|
|
106
112
|
const shouldReformatAfterUserInput = (newValue: string) => {
|
|
107
113
|
// don't reformat if formatting would wipe out user's input
|
|
@@ -246,6 +252,7 @@ export const AmountInput = ({
|
|
|
246
252
|
const addonContent = useMemo((): string | null | undefined => {
|
|
247
253
|
// because we're using a separate "addon" element for the placeholder decimals, there is a possibility that the input itself will become scrollable
|
|
248
254
|
// and the decimals will appear on top of the input. Safest thing to do is to just hide the addon if there is not enough room
|
|
255
|
+
// eslint-disable-next-line react-hooks/refs -- Reading layout dimensions for overflow detection
|
|
249
256
|
if (isInputPossiblyOverflowing({ ref, value })) {
|
|
250
257
|
return null;
|
|
251
258
|
}
|
|
@@ -284,7 +291,7 @@ export const AmountInput = ({
|
|
|
284
291
|
// whenever decimals are shown, we need to account for the full decimal part for the font size calculation
|
|
285
292
|
value: addonContent ? valueWithFullDecimals : value,
|
|
286
293
|
focus: visualFocus,
|
|
287
|
-
inputElement: ref
|
|
294
|
+
inputElement: ref,
|
|
288
295
|
loading,
|
|
289
296
|
});
|
|
290
297
|
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type CSSProperties,
|
|
3
|
+
type RefObject,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import { Props as ExpressiveMoneyInputProps } from '../ExpressiveMoneyInput';
|
|
3
10
|
|
|
4
11
|
type InputStyleObject = {
|
|
5
12
|
value: string;
|
|
6
13
|
focus: boolean;
|
|
7
|
-
inputElement: HTMLInputElement | null;
|
|
14
|
+
inputElement: RefObject<HTMLInputElement> | null;
|
|
8
15
|
} & Pick<ExpressiveMoneyInputProps, 'loading'>;
|
|
9
16
|
|
|
10
17
|
export const useInputStyle = ({ value, focus, inputElement, loading }: InputStyleObject) => {
|
|
11
18
|
const initialRender = !useTimeout(300);
|
|
12
|
-
const inputWidth = useFirstDefinedValue(inputElement?.clientWidth, [
|
|
19
|
+
const inputWidth = useFirstDefinedValue(inputElement?.current?.clientWidth, [
|
|
20
|
+
inputElement?.current,
|
|
21
|
+
value,
|
|
22
|
+
]);
|
|
13
23
|
|
|
14
|
-
const getStyle = (): CSSProperties => {
|
|
24
|
+
const getStyle = useCallback((): CSSProperties => {
|
|
15
25
|
const fontSize = getFontSize(value, focus, inputWidth);
|
|
16
26
|
|
|
17
27
|
return {
|
|
@@ -23,13 +33,14 @@ export const useInputStyle = ({ value, focus, inputElement, loading }: InputStyl
|
|
|
23
33
|
transition: initialRender ? 'none' : undefined,
|
|
24
34
|
color: loading ? 'var(--color-interactive-secondary)' : undefined,
|
|
25
35
|
};
|
|
26
|
-
};
|
|
36
|
+
}, [value, focus, inputWidth, loading, initialRender]);
|
|
27
37
|
|
|
28
|
-
const [style, setStyle] = useState(getStyle());
|
|
38
|
+
const [style, setStyle] = useState<CSSProperties>(() => getStyle());
|
|
29
39
|
|
|
30
40
|
useLayoutEffect(() => {
|
|
41
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Computing style based on layout measurements
|
|
31
42
|
setStyle(getStyle());
|
|
32
|
-
}, [value, focus, loading, inputWidth]);
|
|
43
|
+
}, [value, focus, loading, inputWidth, getStyle]);
|
|
33
44
|
|
|
34
45
|
return style;
|
|
35
46
|
};
|
|
@@ -60,10 +71,11 @@ const useFirstDefinedValue = (newValue: number | undefined, dependencies: unknow
|
|
|
60
71
|
|
|
61
72
|
useLayoutEffect(() => {
|
|
62
73
|
if (typeof newValue !== 'undefined' && typeof value === 'undefined') {
|
|
74
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Lazy initialization from prop
|
|
63
75
|
setValue(newValue);
|
|
64
76
|
}
|
|
65
77
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
66
|
-
}, [...dependencies, value]);
|
|
78
|
+
}, [...dependencies, value, newValue]);
|
|
67
79
|
|
|
68
80
|
return value;
|
|
69
81
|
};
|
|
@@ -15,9 +15,11 @@ export const useSelectionRange = () => {
|
|
|
15
15
|
selection.current = undefined;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
/* eslint-disable react-hooks/refs */
|
|
18
19
|
return {
|
|
19
20
|
selection: selection.current,
|
|
20
21
|
handleSelect,
|
|
21
22
|
handleSelectionBlur,
|
|
22
23
|
};
|
|
24
|
+
/* eslint-enable react-hooks/refs */
|
|
23
25
|
};
|
package/src/header/Header.tsx
CHANGED
|
@@ -104,7 +104,7 @@ const Header: FunctionComponent<HeaderProps> = React.forwardRef(
|
|
|
104
104
|
useEffect(() => {
|
|
105
105
|
if (as === 'legend' && internalRef.current) {
|
|
106
106
|
const { parentElement } = internalRef.current;
|
|
107
|
-
if (
|
|
107
|
+
if (parentElement?.tagName.toLowerCase() !== 'fieldset') {
|
|
108
108
|
console.warn(
|
|
109
109
|
'Legends should be the first child in a fieldset, and this is not possible when including an action',
|
|
110
110
|
);
|
|
@@ -121,7 +121,7 @@ const Header: FunctionComponent<HeaderProps> = React.forwardRef(
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
return (
|
|
124
|
-
<div {...commonProps} {...props} ref={ref
|
|
124
|
+
<div {...commonProps} {...props} ref={ref}>
|
|
125
125
|
<Title as={as} type={levelTypography} className="np-header__title">
|
|
126
126
|
{title}
|
|
127
127
|
</Title>
|
|
@@ -65,10 +65,12 @@ export function SelectInputBottomSheet({
|
|
|
65
65
|
return (
|
|
66
66
|
<>
|
|
67
67
|
{open ? <PreventScroll /> : null}
|
|
68
|
+
{/* eslint-disable react-hooks/refs -- setReference is a callback ref from floating-ui, safe to pass during render */}
|
|
68
69
|
{renderTrigger?.({
|
|
69
70
|
ref: refs.setReference,
|
|
70
71
|
getInteractionProps: getReferenceProps,
|
|
71
72
|
})}
|
|
73
|
+
{/* eslint-enable react-hooks/refs */}
|
|
72
74
|
|
|
73
75
|
<FloatingPortal>
|
|
74
76
|
<ThemeProvider theme="personal" screenMode={theme === 'personal' ? screenMode : 'light'}>
|
|
@@ -94,6 +96,7 @@ export function SelectInputBottomSheet({
|
|
|
94
96
|
<Fragment
|
|
95
97
|
key={floatingKey} // Force inner state invalidation on open
|
|
96
98
|
>
|
|
99
|
+
{/* eslint-disable react-hooks/refs -- setFloating is a callback ref from floating-ui, safe to pass during render */}
|
|
97
100
|
<TransitionChild
|
|
98
101
|
ref={refs.setFloating}
|
|
99
102
|
as="div"
|
|
@@ -102,6 +105,7 @@ export function SelectInputBottomSheet({
|
|
|
102
105
|
leaveTo="np-bottom-sheet-v2-content--closed"
|
|
103
106
|
{...getFloatingProps()}
|
|
104
107
|
>
|
|
108
|
+
{/* eslint-enable react-hooks/refs */}
|
|
105
109
|
<div className="np-bottom-sheet-v2-header">
|
|
106
110
|
<CloseButton
|
|
107
111
|
size={Size.SMALL}
|
|
@@ -77,7 +77,7 @@ export function SelectInputOptions<T = string>({
|
|
|
77
77
|
const intl = useIntl();
|
|
78
78
|
const virtualiserHandlerRef = useRef<VirtualizerHandle>(null);
|
|
79
79
|
const controllerRef = filterable ? searchInputRef : listboxRef;
|
|
80
|
-
const
|
|
80
|
+
const initialRenderRef = useRef(true);
|
|
81
81
|
|
|
82
82
|
const needle = useMemo(() => {
|
|
83
83
|
if (filterable) {
|
|
@@ -166,28 +166,42 @@ export function SelectInputOptions<T = string>({
|
|
|
166
166
|
// Items shown once shall be kept mounted until the needle changes, otherwise
|
|
167
167
|
// the scroll position may jump around inadvertently. Pattern adopted from:
|
|
168
168
|
// https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only
|
|
169
|
-
const [
|
|
170
|
-
|
|
171
|
-
|
|
169
|
+
const [virtualState, setVirtualState] = useState<{
|
|
170
|
+
needle: typeof needle;
|
|
171
|
+
length: number;
|
|
172
|
+
mountedIndexes: number[];
|
|
173
|
+
}>({
|
|
174
|
+
needle,
|
|
175
|
+
length: filteredItems.length,
|
|
176
|
+
mountedIndexes: [],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Note: virtualState.mountedIndexes is in deps but only read in the guarded branch.
|
|
180
|
+
// This means external updates to mountedIndexes will trigger this effect but hit the guard
|
|
181
|
+
// and bail out early. This is intentional and harmless - the guard ensures no unnecessary work.
|
|
172
182
|
useEffect(() => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (filteredItems.length > 0) {
|
|
185
|
-
setMountedIndexes((prevMountedIndexes) => {
|
|
186
|
-
// Create a new array with existing indexes plus the last item index
|
|
187
|
-
return [...new Set([...prevMountedIndexes, filteredItems.length - 1])]; // Sorting is redundant by nature here
|
|
183
|
+
if (virtualState.needle !== needle || virtualState.length !== filteredItems.length) {
|
|
184
|
+
const needleChanged = virtualState.needle !== needle;
|
|
185
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing virtual scroll state with filtered items
|
|
186
|
+
setVirtualState({
|
|
187
|
+
needle,
|
|
188
|
+
length: filteredItems.length,
|
|
189
|
+
mountedIndexes: needleChanged
|
|
190
|
+
? [] // Reset on needle change
|
|
191
|
+
: filteredItems.length > 0
|
|
192
|
+
? [...new Set([...virtualState.mountedIndexes, filteredItems.length - 1])] // Add last index
|
|
193
|
+
: virtualState.mountedIndexes,
|
|
188
194
|
});
|
|
189
195
|
}
|
|
190
|
-
}, [
|
|
196
|
+
}, [
|
|
197
|
+
needle,
|
|
198
|
+
filteredItems.length,
|
|
199
|
+
virtualState.needle,
|
|
200
|
+
virtualState.length,
|
|
201
|
+
virtualState.mountedIndexes,
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
const { mountedIndexes } = virtualState;
|
|
191
205
|
|
|
192
206
|
const listboxContainerRef = useRef<HTMLDivElement>(null);
|
|
193
207
|
useEffect(() => {
|
|
@@ -200,7 +214,7 @@ export function SelectInputOptions<T = string>({
|
|
|
200
214
|
}, []);
|
|
201
215
|
|
|
202
216
|
useEffect(() => {
|
|
203
|
-
|
|
217
|
+
initialRenderRef.current = false;
|
|
204
218
|
}, []);
|
|
205
219
|
|
|
206
220
|
const showStatus = resultsEmpty;
|
|
@@ -251,7 +265,7 @@ export function SelectInputOptions<T = string>({
|
|
|
251
265
|
className="np-select-input-options-container"
|
|
252
266
|
onAriaActiveDescendantChange={(value: React.AriaAttributes['aria-activedescendant']) => {
|
|
253
267
|
if (controllerRef.current != null) {
|
|
254
|
-
if (!
|
|
268
|
+
if (!initialRenderRef.current && value != null) {
|
|
255
269
|
controllerRef.current.setAttribute('aria-activedescendant', value);
|
|
256
270
|
} else {
|
|
257
271
|
controllerRef.current.removeAttribute('aria-activedescendant');
|
|
@@ -288,7 +302,7 @@ export function SelectInputOptions<T = string>({
|
|
|
288
302
|
const inputValue = event.currentTarget.value;
|
|
289
303
|
|
|
290
304
|
// Free up resources and ensure not to go out of bounds
|
|
291
|
-
|
|
305
|
+
setVirtualState((prev) => ({ ...prev, mountedIndexes: [] }));
|
|
292
306
|
onFilterChange(inputValue);
|
|
293
307
|
}}
|
|
294
308
|
onInput={(event) => {
|
|
@@ -358,7 +372,7 @@ export function SelectInputOptions<T = string>({
|
|
|
358
372
|
virtualiserHandlerRef.current.viewportSize,
|
|
359
373
|
);
|
|
360
374
|
|
|
361
|
-
|
|
375
|
+
setVirtualState((prev) => {
|
|
362
376
|
// Create an array of all indexes that should be visible
|
|
363
377
|
|
|
364
378
|
const visibleIndexes = [];
|
|
@@ -368,9 +382,11 @@ export function SelectInputOptions<T = string>({
|
|
|
368
382
|
}
|
|
369
383
|
|
|
370
384
|
// Combine with previous indexes and sort
|
|
371
|
-
|
|
372
|
-
(
|
|
373
|
-
);
|
|
385
|
+
const newMountedIndexes = [
|
|
386
|
+
...new Set([...prev.mountedIndexes, ...visibleIndexes]),
|
|
387
|
+
].sort((a, b) => a - b);
|
|
388
|
+
|
|
389
|
+
return { ...prev, mountedIndexes: newMountedIndexes };
|
|
374
390
|
});
|
|
375
391
|
}}
|
|
376
392
|
>
|
|
@@ -85,10 +85,12 @@ export function SelectInputPopover({
|
|
|
85
85
|
return (
|
|
86
86
|
<>
|
|
87
87
|
{open ? <PreventScroll /> : null}
|
|
88
|
+
{/* eslint-disable react-hooks/refs -- setReference is a callback ref from floating-ui, safe to pass during render */}
|
|
88
89
|
{renderTrigger({
|
|
89
90
|
ref: refs.setReference,
|
|
90
91
|
getInteractionProps: getReferenceProps,
|
|
91
92
|
})}
|
|
93
|
+
{/* eslint-enable react-hooks/refs */}
|
|
92
94
|
|
|
93
95
|
<FloatingPortal>
|
|
94
96
|
<ThemeProvider theme="personal" screenMode={theme === 'personal' ? screenMode : 'light'}>
|
|
@@ -104,6 +106,7 @@ export function SelectInputPopover({
|
|
|
104
106
|
>
|
|
105
107
|
<FocusScope>
|
|
106
108
|
<FloatingFocusManager context={context}>
|
|
109
|
+
{/* eslint-disable react-hooks/refs -- setFloating is a callback ref from floating-ui, safe to pass during render */}
|
|
107
110
|
<div
|
|
108
111
|
key={floatingKey} // Force inner state invalidation on open
|
|
109
112
|
ref={refs.setFloating}
|
|
@@ -114,6 +117,7 @@ export function SelectInputPopover({
|
|
|
114
117
|
style={floatingStyles}
|
|
115
118
|
{...getFloatingProps()}
|
|
116
119
|
>
|
|
120
|
+
{/* eslint-enable react-hooks/refs */}
|
|
117
121
|
<div
|
|
118
122
|
className={clsx('np-popover-v2', title && 'np-popover-v2--has-title', {
|
|
119
123
|
'np-popover-v2--padding-md': padding === 'md',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import mergeProps from 'merge-props';
|
|
2
|
-
import { useEffect, useRef, useState, useDeferredValue } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, useDeferredValue } from 'react';
|
|
3
3
|
import { Listbox as ListboxBase } from '@headlessui/react';
|
|
4
4
|
import { useScreenSize } from '../../common/hooks/useScreenSize';
|
|
5
5
|
import { Breakpoint } from '../../common/propsValues/breakpoint';
|
|
@@ -60,6 +60,7 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
60
60
|
const initialized = useRef(false);
|
|
61
61
|
const handleClose = useEffectEvent(onClose ?? (() => {}));
|
|
62
62
|
const handleOpen = useEffectEvent(onOpen ?? (() => {}));
|
|
63
|
+
|
|
63
64
|
useEffect(() => {
|
|
64
65
|
if (initialized.current) {
|
|
65
66
|
if (open) {
|
|
@@ -70,29 +71,34 @@ export function SelectInput<T = string, M extends boolean = false>({
|
|
|
70
71
|
} else {
|
|
71
72
|
initialized.current = true;
|
|
72
73
|
}
|
|
73
|
-
}, [
|
|
74
|
+
}, [open]);
|
|
74
75
|
|
|
75
76
|
const [filterQuery, _setFilterQuery] = useState('');
|
|
76
77
|
const deferredFilterQuery = useDeferredValue(filterQuery);
|
|
77
|
-
const
|
|
78
|
-
_setFilterQuery(query);
|
|
79
|
-
if (query !== filterQuery) {
|
|
80
|
-
onFilterChange({
|
|
81
|
-
query,
|
|
82
|
-
queryNormalized: query ? searchableString(query) : null,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const internalTriggerRef = useRef<HTMLButtonElement | null>(null);
|
|
78
|
+
const previousFilterQueryRef = useRef(filterQuery);
|
|
88
79
|
|
|
89
|
-
const
|
|
90
|
-
|
|
80
|
+
const setFilterQuery = useCallback(
|
|
81
|
+
(query: string) => {
|
|
82
|
+
_setFilterQuery(query);
|
|
83
|
+
if (query !== previousFilterQueryRef.current) {
|
|
84
|
+
onFilterChange({
|
|
85
|
+
query,
|
|
86
|
+
queryNormalized: query ? searchableString(query) : null,
|
|
87
|
+
});
|
|
88
|
+
previousFilterQueryRef.current = query;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[onFilterChange],
|
|
92
|
+
);
|
|
91
93
|
|
|
94
|
+
const internalTriggerRef = useRef<HTMLButtonElement | null>(null);
|
|
92
95
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
93
96
|
const listboxRef = useRef<HTMLDivElement>(null);
|
|
94
97
|
const controllerRef = filterable ? searchInputRef : listboxRef;
|
|
95
98
|
|
|
99
|
+
const screenSm = useScreenSize(Breakpoint.SMALL);
|
|
100
|
+
const OptionsOverlay = screenSm ? SelectInputPopover : SelectInputBottomSheet;
|
|
101
|
+
|
|
96
102
|
/**
|
|
97
103
|
* Attempts to resolve the `listbox` label
|
|
98
104
|
* @see https://storybook.wise.design/?path=/docs/forms-selectinput-accessibility--docs#labelling
|
|
@@ -29,7 +29,7 @@ export function SelectInputTriggerButton<T extends SelectInputTriggerButtonEleme
|
|
|
29
29
|
ref={ref}
|
|
30
30
|
as={PolymorphicWithOverrides}
|
|
31
31
|
role="combobox"
|
|
32
|
-
__overrides={{ as, size, ...interactionProps }
|
|
32
|
+
__overrides={{ as, size, ...interactionProps }}
|
|
33
33
|
{...mergeProps({ onClick, onKeyDown }, restProps)}
|
|
34
34
|
/>
|
|
35
35
|
);
|
package/src/nudge/Nudge.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Illustration, Assets, type IllustrationNames } from '@wise/art';
|
|
2
2
|
import { clsx } from 'clsx';
|
|
3
|
-
import { ReactNode, useEffect, useState, MouseEvent } from 'react';
|
|
3
|
+
import { ReactNode, useEffect, useState, MouseEvent, useCallback } from 'react';
|
|
4
4
|
|
|
5
5
|
import Body from '../body';
|
|
6
6
|
import { Typography } from '../common';
|
|
@@ -96,8 +96,27 @@ const Nudge = ({
|
|
|
96
96
|
action,
|
|
97
97
|
}: Props) => {
|
|
98
98
|
const intl = useIntl();
|
|
99
|
-
const
|
|
100
|
-
|
|
99
|
+
const getIsDismissed = useCallback(
|
|
100
|
+
() => (persistDismissal && id ? !!getLocalStorage()?.find((item) => item === id) : false),
|
|
101
|
+
[persistDismissal, id],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const [nudgeState, setNudgeState] = useState(() => ({
|
|
105
|
+
isDismissed: getIsDismissed(),
|
|
106
|
+
isMounted: false,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Setting mount state in mount effect
|
|
111
|
+
setNudgeState((prev) => ({ ...prev, isMounted: true }));
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing dismissed state from localStorage on prop change
|
|
116
|
+
setNudgeState((prev) => ({ ...prev, isDismissed: getIsDismissed() }));
|
|
117
|
+
}, [getIsDismissed, id, persistDismissal]);
|
|
118
|
+
|
|
119
|
+
const { isDismissed } = nudgeState;
|
|
101
120
|
|
|
102
121
|
const handleOnDismiss = () => {
|
|
103
122
|
const dismissedNudgesStorage = getLocalStorage();
|
|
@@ -105,9 +124,9 @@ const Nudge = ({
|
|
|
105
124
|
if (persistDismissal && id) {
|
|
106
125
|
try {
|
|
107
126
|
localStorage.setItem(STORAGE_NAME, JSON.stringify([...dismissedNudgesStorage, id]));
|
|
108
|
-
} catch
|
|
127
|
+
} catch {}
|
|
109
128
|
|
|
110
|
-
|
|
129
|
+
setNudgeState((prev) => ({ ...prev, isDismissed: true }));
|
|
111
130
|
}
|
|
112
131
|
|
|
113
132
|
if (onDismiss) {
|
|
@@ -116,25 +135,15 @@ const Nudge = ({
|
|
|
116
135
|
};
|
|
117
136
|
|
|
118
137
|
useEffect(() => {
|
|
119
|
-
if (persistDismissal && id) {
|
|
138
|
+
if (persistDismissal && id && isPreviouslyDismissed) {
|
|
120
139
|
const dismissedNudgesStorage = getLocalStorage();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (dismissedNudgesStorage?.find((item) => item === id)) {
|
|
124
|
-
setIsDismissed(true);
|
|
125
|
-
isDismissed = true;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (isPreviouslyDismissed) {
|
|
129
|
-
isPreviouslyDismissed(isDismissed);
|
|
130
|
-
}
|
|
140
|
+
const wasDismissed = !!dismissedNudgesStorage?.find((item) => item === id);
|
|
141
|
+
isPreviouslyDismissed(wasDismissed);
|
|
131
142
|
}
|
|
132
|
-
|
|
133
|
-
setIsMounted(true);
|
|
134
143
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
135
144
|
}, [id, persistDismissal]);
|
|
136
145
|
|
|
137
|
-
if (persistDismissal && (isDismissed || !isMounted)) {
|
|
146
|
+
if (persistDismissal && (isDismissed || !nudgeState.isMounted)) {
|
|
138
147
|
return null;
|
|
139
148
|
}
|
|
140
149
|
|
|
@@ -143,7 +152,7 @@ const Nudge = ({
|
|
|
143
152
|
{!!mediaName && (
|
|
144
153
|
<div className="wds-nudge-media">
|
|
145
154
|
<Illustration
|
|
146
|
-
name={mediaName
|
|
155
|
+
name={mediaName}
|
|
147
156
|
className={clsx(`wds-nudge-media-${mediaName}`)}
|
|
148
157
|
size="small"
|
|
149
158
|
disablePadding
|
|
@@ -283,6 +283,22 @@ describe('PhoneNumberInput', () => {
|
|
|
283
283
|
expect(props.onChange).toHaveBeenCalledWith('+201111111', '+20');
|
|
284
284
|
});
|
|
285
285
|
});
|
|
286
|
+
|
|
287
|
+
describe('onChange deduplication', () => {
|
|
288
|
+
it('should not call onChange when the composed phone number has not changed', () => {
|
|
289
|
+
const onChangeMock = jest.fn();
|
|
290
|
+
const { rerender } = render(
|
|
291
|
+
<PhoneNumberInput initialValue="+441234567890" onChange={onChangeMock} />,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
onChangeMock.mockClear();
|
|
295
|
+
|
|
296
|
+
// Rerender with the same initialValue - internal state should not trigger onChange
|
|
297
|
+
rerender(<PhoneNumberInput initialValue="+441234567890" onChange={onChangeMock} />);
|
|
298
|
+
|
|
299
|
+
expect(onChangeMock).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
286
302
|
});
|
|
287
303
|
|
|
288
304
|
describe('when selectProps is supplied', () => {
|
|
@@ -75,12 +75,13 @@ const PhoneNumberInput = ({
|
|
|
75
75
|
|
|
76
76
|
const { locale, formatMessage } = useIntl();
|
|
77
77
|
|
|
78
|
+
const [randomId] = useState(() => Math.random().toString(36).slice(2, 8));
|
|
79
|
+
|
|
78
80
|
const createId = (customID: string | undefined, backup: string): string => {
|
|
79
81
|
if (customID) {
|
|
80
82
|
return customID + (backup ? `-${backup}` : '');
|
|
81
83
|
}
|
|
82
|
-
|
|
83
|
-
return `${backup}-${random}`;
|
|
84
|
+
return `${backup}-${randomId}`;
|
|
84
85
|
};
|
|
85
86
|
|
|
86
87
|
// Link the first non-disabled input to the the Field label, if present
|
|
@@ -107,14 +108,16 @@ const PhoneNumberInput = ({
|
|
|
107
108
|
|
|
108
109
|
return explodeNumberModel(cleanValue);
|
|
109
110
|
});
|
|
110
|
-
const
|
|
111
|
+
const broadcastedValueRef = useRef<PhoneNumber>(internalValue);
|
|
111
112
|
|
|
112
113
|
const [suffixDirty, setSuffixDirty] = useState(false);
|
|
114
|
+
|
|
113
115
|
useEffect(() => {
|
|
114
|
-
if (internalValue.suffix) {
|
|
116
|
+
if (!suffixDirty && internalValue.suffix) {
|
|
117
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Tracking when suffix becomes dirty
|
|
115
118
|
setSuffixDirty(true);
|
|
116
119
|
}
|
|
117
|
-
}, [internalValue.suffix]);
|
|
120
|
+
}, [internalValue.suffix, suffixDirty]);
|
|
118
121
|
|
|
119
122
|
const countriesByPrefix = useMemo(
|
|
120
123
|
() =>
|
|
@@ -152,13 +155,8 @@ const PhoneNumberInput = ({
|
|
|
152
155
|
};
|
|
153
156
|
|
|
154
157
|
useEffect(() => {
|
|
155
|
-
if (broadcastedValue === null) {
|
|
156
|
-
setBroadcastedValue(internalValue);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
158
|
const internalPhoneNumber = `${internalValue.prefix ?? ''}${internalValue.suffix}`;
|
|
161
|
-
const broadcastedPhoneNumber = `${
|
|
159
|
+
const broadcastedPhoneNumber = `${broadcastedValueRef.current.prefix ?? ''}${broadcastedValueRef.current.suffix}`;
|
|
162
160
|
|
|
163
161
|
if (internalPhoneNumber === broadcastedPhoneNumber) {
|
|
164
162
|
return;
|
|
@@ -172,8 +170,8 @@ const PhoneNumberInput = ({
|
|
|
172
170
|
newValue,
|
|
173
171
|
internalValue.prefix ?? '', // TODO: Allow `null` in public API
|
|
174
172
|
);
|
|
175
|
-
|
|
176
|
-
}, [onChange,
|
|
173
|
+
broadcastedValueRef.current = internalValue;
|
|
174
|
+
}, [onChange, internalValue]);
|
|
177
175
|
|
|
178
176
|
useEffect(() => {
|
|
179
177
|
const labelRef = fieldLabelRef?.current;
|
|
@@ -47,7 +47,7 @@ export const TaskCard: Story = {
|
|
|
47
47
|
isSmall: true,
|
|
48
48
|
useDisplayFont: false,
|
|
49
49
|
className: 'taskCard',
|
|
50
|
-
}
|
|
50
|
+
},
|
|
51
51
|
decorators: [
|
|
52
52
|
(Story) => (
|
|
53
53
|
<div>
|
|
@@ -90,7 +90,7 @@ export const TaskCardWithCustomIcon: Story = {
|
|
|
90
90
|
args: {
|
|
91
91
|
...TaskCard.args,
|
|
92
92
|
indicatorIcon: <StarFill size={24} aria-hidden="true" />,
|
|
93
|
-
}
|
|
93
|
+
},
|
|
94
94
|
decorators: TaskCard.decorators,
|
|
95
95
|
};
|
|
96
96
|
|
|
@@ -101,7 +101,7 @@ export const TaskCardCompleted: Story = {
|
|
|
101
101
|
href: undefined,
|
|
102
102
|
indicatorIcon: 'check',
|
|
103
103
|
className: 'taskCard taskCard--completed np-theme--personal np-theme-personal--forest-green',
|
|
104
|
-
}
|
|
104
|
+
},
|
|
105
105
|
decorators: TaskCard.decorators,
|
|
106
106
|
};
|
|
107
107
|
|