@transferwise/components 46.162.2 → 46.164.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/Chips/Chip/Chip.js +11 -4
- package/build/Chips/Chip/Chip.js.map +1 -1
- package/build/Chips/Chip/Chip.mjs +11 -4
- package/build/Chips/Chip/Chip.mjs.map +1 -1
- package/build/FlowNavigation/FlowNavigation.js +4 -2
- package/build/FlowNavigation/FlowNavigation.js.map +1 -1
- package/build/FlowNavigation/FlowNavigation.mjs +4 -2
- package/build/FlowNavigation/FlowNavigation.mjs.map +1 -1
- package/build/FlowNavigation/helpers.js +24 -0
- package/build/FlowNavigation/helpers.js.map +1 -0
- package/build/FlowNavigation/helpers.mjs +21 -0
- package/build/FlowNavigation/helpers.mjs.map +1 -0
- package/build/Typeahead/Typeahead.js +17 -4
- package/build/Typeahead/Typeahead.js.map +1 -1
- package/build/Typeahead/Typeahead.mjs +17 -4
- package/build/Typeahead/Typeahead.mjs.map +1 -1
- package/build/Typeahead/TypeaheadInput/TypeaheadInput.js +2 -0
- package/build/Typeahead/TypeaheadInput/TypeaheadInput.js.map +1 -1
- package/build/Typeahead/TypeaheadInput/TypeaheadInput.mjs +2 -0
- package/build/Typeahead/TypeaheadInput/TypeaheadInput.mjs.map +1 -1
- package/build/i18n/cs.json +1 -0
- package/build/i18n/cs.json.js +1 -0
- package/build/i18n/cs.json.js.map +1 -1
- package/build/i18n/cs.json.mjs +1 -0
- package/build/i18n/cs.json.mjs.map +1 -1
- package/build/i18n/de.json +1 -0
- package/build/i18n/de.json.js +1 -0
- package/build/i18n/de.json.js.map +1 -1
- package/build/i18n/de.json.mjs +1 -0
- package/build/i18n/de.json.mjs.map +1 -1
- package/build/i18n/es.json +1 -0
- package/build/i18n/es.json.js +1 -0
- package/build/i18n/es.json.js.map +1 -1
- package/build/i18n/es.json.mjs +1 -0
- package/build/i18n/es.json.mjs.map +1 -1
- package/build/i18n/fr.json +1 -0
- package/build/i18n/fr.json.js +1 -0
- package/build/i18n/fr.json.js.map +1 -1
- package/build/i18n/fr.json.mjs +1 -0
- package/build/i18n/fr.json.mjs.map +1 -1
- package/build/i18n/hu.json +1 -0
- package/build/i18n/hu.json.js +1 -0
- package/build/i18n/hu.json.js.map +1 -1
- package/build/i18n/hu.json.mjs +1 -0
- package/build/i18n/hu.json.mjs.map +1 -1
- package/build/i18n/id.json +1 -0
- package/build/i18n/id.json.js +1 -0
- package/build/i18n/id.json.js.map +1 -1
- package/build/i18n/id.json.mjs +1 -0
- package/build/i18n/id.json.mjs.map +1 -1
- package/build/i18n/it.json +1 -0
- package/build/i18n/it.json.js +1 -0
- package/build/i18n/it.json.js.map +1 -1
- package/build/i18n/it.json.mjs +1 -0
- package/build/i18n/it.json.mjs.map +1 -1
- package/build/i18n/ja.json +1 -0
- package/build/i18n/ja.json.js +1 -0
- package/build/i18n/ja.json.js.map +1 -1
- package/build/i18n/ja.json.mjs +1 -0
- package/build/i18n/ja.json.mjs.map +1 -1
- package/build/i18n/nl.json +1 -0
- package/build/i18n/nl.json.js +1 -0
- package/build/i18n/nl.json.js.map +1 -1
- package/build/i18n/nl.json.mjs +1 -0
- package/build/i18n/nl.json.mjs.map +1 -1
- package/build/i18n/pl.json +1 -0
- package/build/i18n/pl.json.js +1 -0
- package/build/i18n/pl.json.js.map +1 -1
- package/build/i18n/pl.json.mjs +1 -0
- package/build/i18n/pl.json.mjs.map +1 -1
- package/build/i18n/pt.json +1 -0
- package/build/i18n/pt.json.js +1 -0
- package/build/i18n/pt.json.js.map +1 -1
- package/build/i18n/pt.json.mjs +1 -0
- package/build/i18n/pt.json.mjs.map +1 -1
- package/build/i18n/ro.json +1 -0
- package/build/i18n/ro.json.js +1 -0
- package/build/i18n/ro.json.js.map +1 -1
- package/build/i18n/ro.json.mjs +1 -0
- package/build/i18n/ro.json.mjs.map +1 -1
- package/build/i18n/ru.json +1 -0
- package/build/i18n/ru.json.js +1 -0
- package/build/i18n/ru.json.js.map +1 -1
- package/build/i18n/ru.json.mjs +1 -0
- package/build/i18n/ru.json.mjs.map +1 -1
- package/build/i18n/th.json +1 -0
- package/build/i18n/th.json.js +1 -0
- package/build/i18n/th.json.js.map +1 -1
- package/build/i18n/th.json.mjs +1 -0
- package/build/i18n/th.json.mjs.map +1 -1
- package/build/i18n/tr.json +1 -0
- package/build/i18n/tr.json.js +1 -0
- package/build/i18n/tr.json.js.map +1 -1
- package/build/i18n/tr.json.mjs +1 -0
- package/build/i18n/tr.json.mjs.map +1 -1
- package/build/i18n/uk.json +1 -0
- package/build/i18n/uk.json.js +1 -0
- package/build/i18n/uk.json.js.map +1 -1
- package/build/i18n/uk.json.mjs +1 -0
- package/build/i18n/uk.json.mjs.map +1 -1
- package/build/i18n/zh-HK.json +1 -0
- package/build/i18n/zh-HK.json.js +1 -0
- package/build/i18n/zh-HK.json.js.map +1 -1
- package/build/i18n/zh-HK.json.mjs +1 -0
- package/build/i18n/zh-HK.json.mjs.map +1 -1
- package/build/main.css +3 -0
- package/build/styles/Typeahead/Typeahead.css +3 -0
- package/build/styles/main.css +3 -0
- package/build/types/Body/Body.d.ts +2 -2
- package/build/types/Chips/Chip/Chip.d.ts +3 -1
- package/build/types/Chips/Chip/Chip.d.ts.map +1 -1
- package/build/types/FlowNavigation/FlowNavigation.d.ts +6 -2
- package/build/types/FlowNavigation/FlowNavigation.d.ts.map +1 -1
- package/build/types/FlowNavigation/helpers.d.ts +5 -0
- package/build/types/FlowNavigation/helpers.d.ts.map +1 -0
- package/build/types/FlowNavigation/index.d.ts +1 -0
- package/build/types/FlowNavigation/index.d.ts.map +1 -1
- package/build/types/FlowNavigation/types.d.ts +15 -0
- package/build/types/FlowNavigation/types.d.ts.map +1 -0
- package/build/types/IconButton/IconButton.d.ts +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts +1 -1
- package/build/types/MoneyInput/MoneyInput.d.ts.map +1 -1
- package/build/types/Title/Title.d.ts +2 -2
- package/build/types/Typeahead/Typeahead.d.ts +2 -0
- package/build/types/Typeahead/Typeahead.d.ts.map +1 -1
- package/build/types/Typeahead/TypeaheadInput/TypeaheadInput.d.ts +1 -1
- package/build/types/Typeahead/TypeaheadInput/TypeaheadInput.d.ts.map +1 -1
- package/build/types/Upload/Steps/UploadImageStep/UploadImageStep.d.ts +1 -1
- package/build/types/UploadInput/UploadItem/UploadItemLink.d.ts +1 -1
- package/build/types/index.d.ts +1 -1
- package/build/types/index.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/Chips/Chip/Chip.test.tsx +34 -0
- package/src/Chips/Chip/Chip.tsx +11 -3
- package/src/FlowNavigation/FlowNavigation.test.tsx +45 -0
- package/src/FlowNavigation/FlowNavigation.tsx +14 -3
- package/src/FlowNavigation/_stories/FlowNavigation.CriticalBanner.docs.mdx +66 -0
- package/src/FlowNavigation/_stories/FlowNavigation.story.tsx +49 -0
- package/src/FlowNavigation/helpers.ts +28 -0
- package/src/FlowNavigation/index.ts +1 -0
- package/src/FlowNavigation/types.ts +16 -0
- package/src/Prompt/CriticalBanner/_stories/CriticalBanner.story.tsx +2 -0
- package/src/Snackbar/Snackbar.test.tsx +190 -0
- package/src/Typeahead/Typeahead.css +3 -0
- package/src/Typeahead/Typeahead.less +4 -0
- package/src/Typeahead/Typeahead.test.tsx +73 -0
- package/src/Typeahead/Typeahead.tsx +18 -3
- package/src/Typeahead/TypeaheadInput/TypeaheadInput.tsx +6 -1
- package/src/Typeahead/_stories/Typeahead.story.tsx +1 -0
- package/src/Typeahead/_stories/Typeahead.test.story.tsx +41 -0
- package/src/i18n/cs.json +1 -0
- package/src/i18n/de.json +1 -0
- package/src/i18n/es.json +1 -0
- package/src/i18n/fr.json +1 -0
- package/src/i18n/hu.json +1 -0
- package/src/i18n/id.json +1 -0
- package/src/i18n/it.json +1 -0
- package/src/i18n/ja.json +1 -0
- package/src/i18n/nl.json +1 -0
- package/src/i18n/pl.json +1 -0
- package/src/i18n/pt.json +1 -0
- package/src/i18n/ro.json +1 -0
- package/src/i18n/ru.json +1 -0
- package/src/i18n/th.json +1 -0
- package/src/i18n/tr.json +1 -0
- package/src/i18n/uk.json +1 -0
- package/src/i18n/zh-HK.json +1 -0
- package/src/index.ts +1 -1
- package/src/main.css +3 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Meta, Source } from '@storybook/addon-docs/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="Navigation/FlowNavigation/Critical Banner Guide" />
|
|
4
|
+
|
|
5
|
+
# Critical Banner in FlowNavigation
|
|
6
|
+
|
|
7
|
+
The `FlowNavigation` component supports displaying critical banners above the navigation, by passing a `banner` prop.
|
|
8
|
+
|
|
9
|
+
`banner` accepts either the internal `CriticalBannerProps` shape, or the legacy backend `CriticalBannerData` shape (e.g. as received from `navigation-service`/`navigation-ui`) — `FlowNavigation` maps the legacy shape to `CriticalBannerProps` internally, so no manual mapping is required.
|
|
10
|
+
|
|
11
|
+
## Basic Usage
|
|
12
|
+
|
|
13
|
+
When you're using `FlowNavigation` directly from `@transferwise/components`.
|
|
14
|
+
|
|
15
|
+
<Source
|
|
16
|
+
code={`import { FlowNavigation, CriticalBannerProps } from '@transferwise/components';
|
|
17
|
+
|
|
18
|
+
const banner: CriticalBannerProps = {
|
|
19
|
+
sentiment: 'negative',
|
|
20
|
+
title: 'Action required',
|
|
21
|
+
description: 'Please verify your identity to continue with this transfer.',
|
|
22
|
+
action: {
|
|
23
|
+
label: 'Verify now',
|
|
24
|
+
onClick: () => {
|
|
25
|
+
// Handle action
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
<FlowNavigation
|
|
31
|
+
banner={banner}
|
|
32
|
+
steps={steps}
|
|
33
|
+
activeStep={activeStep}
|
|
34
|
+
avatar={avatar}
|
|
35
|
+
onClose={onClose}
|
|
36
|
+
/>;
|
|
37
|
+
`}
|
|
38
|
+
language="typescript"
|
|
39
|
+
dark
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
## Using Legacy Backend Data
|
|
43
|
+
|
|
44
|
+
If your application receives banner data from a backend service (e.g. navigation-ui) in the legacy shape, you can pass it directly to `banner` — `FlowNavigation` maps it to the internal `CriticalBannerProps` shape automatically.
|
|
45
|
+
|
|
46
|
+
<Source
|
|
47
|
+
code={`import { FlowNavigation, CriticalBannerData } from '@transferwise/components';
|
|
48
|
+
|
|
49
|
+
const banner: CriticalBannerData = {
|
|
50
|
+
id: 'banner-123',
|
|
51
|
+
title: 'Your account requires attention', // Rendered as the banner description
|
|
52
|
+
subtitle: 'Action required', // Rendered as the banner title
|
|
53
|
+
style: 'WARNING',
|
|
54
|
+
action: { label: 'Take action', href: '/action' },
|
|
55
|
+
collapsed: false, // Rendered expanded
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
<FlowNavigation
|
|
59
|
+
banner={banner}
|
|
60
|
+
steps={steps}
|
|
61
|
+
activeStep={activeStep}
|
|
62
|
+
/>;
|
|
63
|
+
`}
|
|
64
|
+
language="typescript"
|
|
65
|
+
dark
|
|
66
|
+
/>
|
|
@@ -646,3 +646,52 @@ export const WithCriticalBanner: Story = {
|
|
|
646
646
|
);
|
|
647
647
|
},
|
|
648
648
|
};
|
|
649
|
+
|
|
650
|
+
export const WithLegacyCriticalBannerData: Story = {
|
|
651
|
+
args: {
|
|
652
|
+
profileType: ProfileType.PERSONAL,
|
|
653
|
+
showCloseButton: true,
|
|
654
|
+
showMobileBackButton: true,
|
|
655
|
+
avatarURL: '../tapestry-01.png',
|
|
656
|
+
},
|
|
657
|
+
parameters: {
|
|
658
|
+
padding: '0',
|
|
659
|
+
},
|
|
660
|
+
render: (args) => {
|
|
661
|
+
const [activeStep, setActiveStep] = useState(2);
|
|
662
|
+
|
|
663
|
+
const steps = [
|
|
664
|
+
{ label: 'Recipient', onClick: () => setActiveStep(0) },
|
|
665
|
+
{ label: 'Amount', ...(activeStep > 1 && { onClick: () => setActiveStep(1) }) },
|
|
666
|
+
{ label: 'Review', ...(activeStep > 2 && { onClick: () => setActiveStep(2) }) },
|
|
667
|
+
{ label: 'Pay', ...(activeStep > 3 && { onClick: () => setActiveStep(3) }) },
|
|
668
|
+
];
|
|
669
|
+
|
|
670
|
+
return (
|
|
671
|
+
// Legacy backend banner data shape - mapped to CriticalBannerProps internally
|
|
672
|
+
<FlowNavigation
|
|
673
|
+
avatar={<AvatarView profileType={args.profileType} />}
|
|
674
|
+
logo={<Logo />}
|
|
675
|
+
activeStep={activeStep}
|
|
676
|
+
steps={steps}
|
|
677
|
+
banner={{
|
|
678
|
+
id: 'banner-123',
|
|
679
|
+
title: 'Your account requires attention',
|
|
680
|
+
subtitle: 'Action required',
|
|
681
|
+
style: 'WARNING',
|
|
682
|
+
action: {
|
|
683
|
+
label: 'Take action',
|
|
684
|
+
href: '#action',
|
|
685
|
+
},
|
|
686
|
+
collapsed: false,
|
|
687
|
+
}}
|
|
688
|
+
onClose={args.showCloseButton ? () => {} : undefined}
|
|
689
|
+
onGoBack={
|
|
690
|
+
args.showMobileBackButton
|
|
691
|
+
? () => setActiveStep(activeStep > 0 ? activeStep - 1 : 0)
|
|
692
|
+
: undefined
|
|
693
|
+
}
|
|
694
|
+
/>
|
|
695
|
+
);
|
|
696
|
+
},
|
|
697
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CriticalBannerProps } from '../Prompt';
|
|
2
|
+
import { CriticalBannerLegacyData } from './types';
|
|
3
|
+
|
|
4
|
+
const sentimentMap: Record<CriticalBannerLegacyData['style'], CriticalBannerProps['sentiment']> = {
|
|
5
|
+
NEGATIVE: 'negative',
|
|
6
|
+
WARNING: 'warning',
|
|
7
|
+
NEUTRAL: 'neutral',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const isCriticalBannerData = (
|
|
11
|
+
banner: CriticalBannerLegacyData | CriticalBannerProps,
|
|
12
|
+
): banner is CriticalBannerLegacyData => 'style' in banner;
|
|
13
|
+
|
|
14
|
+
export const mapCriticalBannerDataToBannerProps = (
|
|
15
|
+
data: CriticalBannerLegacyData,
|
|
16
|
+
): CriticalBannerProps => ({
|
|
17
|
+
id: data.id,
|
|
18
|
+
title: data.title,
|
|
19
|
+
description: data.subtitle, // subtitle → description
|
|
20
|
+
sentiment: sentimentMap[data.style],
|
|
21
|
+
action: data.action
|
|
22
|
+
? {
|
|
23
|
+
label: data.action.label,
|
|
24
|
+
href: data.action.href,
|
|
25
|
+
}
|
|
26
|
+
: undefined,
|
|
27
|
+
expanded: !data.collapsed, // collapsed → expanded (inverted)
|
|
28
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type CriticalBannerStyle = 'WARNING' | 'NEGATIVE' | 'NEUTRAL';
|
|
2
|
+
|
|
3
|
+
export type CriticalBannerAction = {
|
|
4
|
+
label: string;
|
|
5
|
+
href: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type CriticalBannerLegacyData = {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
subtitle?: string;
|
|
12
|
+
style: CriticalBannerStyle;
|
|
13
|
+
action?: CriticalBannerAction;
|
|
14
|
+
mixpanelEvent?: string;
|
|
15
|
+
collapsed?: boolean;
|
|
16
|
+
};
|
|
@@ -14,6 +14,8 @@ import { CriticalBanner, type CriticalBannerProps } from '..';
|
|
|
14
14
|
* **Design guidance**: <a href="https://docs.wise.design/components/critical-banner" target="_blank">wise.design/components/critical-banner</a>
|
|
15
15
|
*
|
|
16
16
|
* Can be controlled or uncontrolled. When `onToggle` is provided, the consumer manages the expanded state via `expanded` and the banner calls `onToggle` when the user clicks the chevron. When `onToggle` is not provided, the banner manages its own expanded state internally and toggles it when the user clicks the chevron.
|
|
17
|
+
*
|
|
18
|
+
* **Using with FlowNavigation**: See the [FlowNavigation Critical Banner Guide](?path=/docs/navigation-flownavigation-critical-banner-guide--docs) for integration patterns and data mapping examples.
|
|
17
19
|
*/
|
|
18
20
|
const meta = {
|
|
19
21
|
component: CriticalBanner,
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { act, render, screen, userEvent } from '../test-utils';
|
|
2
|
+
import Button from '../Button';
|
|
3
|
+
|
|
4
|
+
import { Snackbar, type SnackbarProps } from './Snackbar';
|
|
5
|
+
import { SnackbarConsumer } from './SnackbarContext';
|
|
6
|
+
import SnackbarProvider from './SnackbarProvider';
|
|
7
|
+
|
|
8
|
+
const CSS_TRANSITION_DURATION = 400;
|
|
9
|
+
const TIMEOUT = 4000;
|
|
10
|
+
|
|
11
|
+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTimeAsync });
|
|
12
|
+
|
|
13
|
+
const advanceTimers = (duration: number) => {
|
|
14
|
+
act(() => {
|
|
15
|
+
jest.advanceTimersByTime(duration);
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const renderSnackbar = (props: Partial<SnackbarProps> = {}) =>
|
|
20
|
+
render(<Snackbar text="" timeout={TIMEOUT} timestamp={0} {...props} />);
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.useFakeTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
jest.clearAllTimers();
|
|
28
|
+
jest.useRealTimers();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('Snackbar', () => {
|
|
32
|
+
it('renders the live region wrapper even while no notification is shown', () => {
|
|
33
|
+
renderSnackbar();
|
|
34
|
+
|
|
35
|
+
expect(screen.getByRole('alert')).toHaveAttribute('aria-live', 'assertive');
|
|
36
|
+
expect(screen.getByRole('alert')).toHaveClass('snackbar');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not show a notification that is already present on mount', () => {
|
|
40
|
+
renderSnackbar({ text: 'Changes saved', timestamp: 1 });
|
|
41
|
+
|
|
42
|
+
expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('shows the notification once text arrives after mount', () => {
|
|
46
|
+
const { rerender } = renderSnackbar();
|
|
47
|
+
|
|
48
|
+
rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
|
|
49
|
+
|
|
50
|
+
expect(screen.getByText('Changes saved')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders an action and calls its onClick handler', async () => {
|
|
54
|
+
const onClick = jest.fn();
|
|
55
|
+
const { rerender } = renderSnackbar();
|
|
56
|
+
|
|
57
|
+
rerender(
|
|
58
|
+
<Snackbar
|
|
59
|
+
action={{ label: 'View', onClick }}
|
|
60
|
+
text="Transfer completed"
|
|
61
|
+
timeout={TIMEOUT}
|
|
62
|
+
timestamp={1}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
await user.click(screen.getByRole('button', { name: 'View' }));
|
|
66
|
+
|
|
67
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('dismisses the notification once the timeout elapses', () => {
|
|
71
|
+
const { rerender } = renderSnackbar();
|
|
72
|
+
|
|
73
|
+
rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
|
|
74
|
+
|
|
75
|
+
advanceTimers(TIMEOUT - 1);
|
|
76
|
+
expect(screen.getByText('Changes saved')).toBeInTheDocument();
|
|
77
|
+
|
|
78
|
+
advanceTimers(1);
|
|
79
|
+
advanceTimers(CSS_TRANSITION_DURATION);
|
|
80
|
+
expect(screen.queryByText('Changes saved')).not.toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('exits the current notification before entering the next one', () => {
|
|
84
|
+
const { rerender } = renderSnackbar();
|
|
85
|
+
|
|
86
|
+
rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
|
|
87
|
+
rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
|
|
88
|
+
|
|
89
|
+
advanceTimers(CSS_TRANSITION_DURATION - 1);
|
|
90
|
+
expect(screen.queryByText('Second message')).not.toBeInTheDocument();
|
|
91
|
+
|
|
92
|
+
advanceTimers(1);
|
|
93
|
+
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
94
|
+
expect(screen.queryByText('First message')).not.toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('shows the next notification immediately when the previous one has already been dismissed', () => {
|
|
98
|
+
const { rerender } = renderSnackbar();
|
|
99
|
+
|
|
100
|
+
rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
|
|
101
|
+
advanceTimers(TIMEOUT + CSS_TRANSITION_DURATION);
|
|
102
|
+
|
|
103
|
+
rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
|
|
104
|
+
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('restarts the timeout for a replacing notification', () => {
|
|
108
|
+
const { rerender } = renderSnackbar();
|
|
109
|
+
|
|
110
|
+
rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
|
|
111
|
+
advanceTimers(TIMEOUT - 100);
|
|
112
|
+
|
|
113
|
+
rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={2} />);
|
|
114
|
+
advanceTimers(CSS_TRANSITION_DURATION);
|
|
115
|
+
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
116
|
+
|
|
117
|
+
advanceTimers(TIMEOUT - 1);
|
|
118
|
+
expect(screen.getByText('Second message')).toBeInTheDocument();
|
|
119
|
+
|
|
120
|
+
advanceTimers(1);
|
|
121
|
+
advanceTimers(CSS_TRANSITION_DURATION);
|
|
122
|
+
expect(screen.queryByText('Second message')).not.toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Documents existing behaviour rather than endorsing it: clearing the text leaves the notification
|
|
126
|
+
// on screen indefinitely, because the previous shouldComponentUpdate blocked the dismissal too.
|
|
127
|
+
it('keeps the notification visible for as long as text is empty', () => {
|
|
128
|
+
const { rerender } = renderSnackbar();
|
|
129
|
+
|
|
130
|
+
rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
|
|
131
|
+
rerender(<Snackbar text="" timeout={TIMEOUT} timestamp={2} />);
|
|
132
|
+
|
|
133
|
+
advanceTimers(TIMEOUT + CSS_TRANSITION_DURATION);
|
|
134
|
+
expect(screen.getByText('Changes saved')).toBeInTheDocument();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('ignores a repeated timestamp', () => {
|
|
138
|
+
const { rerender } = renderSnackbar();
|
|
139
|
+
|
|
140
|
+
rerender(<Snackbar text="First message" timeout={TIMEOUT} timestamp={1} />);
|
|
141
|
+
rerender(<Snackbar text="Second message" timeout={TIMEOUT} timestamp={1} />);
|
|
142
|
+
|
|
143
|
+
advanceTimers(CSS_TRANSITION_DURATION);
|
|
144
|
+
expect(screen.getByText('First message')).toBeInTheDocument();
|
|
145
|
+
expect(screen.queryByText('Second message')).not.toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('clears its pending dismissal timer on unmount', () => {
|
|
149
|
+
const { rerender, unmount } = renderSnackbar();
|
|
150
|
+
|
|
151
|
+
rerender(<Snackbar text="Changes saved" timeout={TIMEOUT} timestamp={1} />);
|
|
152
|
+
const pendingTimers = jest.getTimerCount();
|
|
153
|
+
unmount();
|
|
154
|
+
|
|
155
|
+
// The timer left behind belongs to CSSTransition's enter timeout, not to Snackbar.
|
|
156
|
+
expect(jest.getTimerCount()).toBe(pendingTimers - 1);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('Snackbar created through SnackbarProvider', () => {
|
|
161
|
+
const renderProvider = () =>
|
|
162
|
+
render(
|
|
163
|
+
<SnackbarProvider timeout={TIMEOUT}>
|
|
164
|
+
<SnackbarConsumer>
|
|
165
|
+
{({ createSnackbar }) => (
|
|
166
|
+
<Button onClick={() => createSnackbar({ text: 'Changes saved' })}>Show</Button>
|
|
167
|
+
)}
|
|
168
|
+
</SnackbarConsumer>
|
|
169
|
+
</SnackbarProvider>,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
it('shows a notification created through the context', async () => {
|
|
173
|
+
renderProvider();
|
|
174
|
+
|
|
175
|
+
await user.click(screen.getByRole('button', { name: 'Show' }));
|
|
176
|
+
|
|
177
|
+
expect(screen.getByText('Changes saved')).toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('shows only one notification when the trigger is clicked repeatedly', async () => {
|
|
181
|
+
renderProvider();
|
|
182
|
+
|
|
183
|
+
const trigger = screen.getByRole('button', { name: 'Show' });
|
|
184
|
+
await user.click(trigger);
|
|
185
|
+
await user.click(trigger);
|
|
186
|
+
await user.click(trigger);
|
|
187
|
+
|
|
188
|
+
expect(screen.getAllByRole('alert')).toHaveLength(1);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -256,4 +256,77 @@ describe('Typeahead', () => {
|
|
|
256
256
|
expect.arrayContaining([{ label: 'foo' }, { label: 'bar' }]),
|
|
257
257
|
);
|
|
258
258
|
});
|
|
259
|
+
|
|
260
|
+
describe('when disabled', () => {
|
|
261
|
+
it('disables the input', () => {
|
|
262
|
+
render(<Typeahead id="test" name="test" options={[]} disabled onChange={() => {}} />);
|
|
263
|
+
expect(screen.getByRole('combobox')).toBeDisabled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('does not show the clear button when there is a value', () => {
|
|
267
|
+
render(
|
|
268
|
+
<Typeahead
|
|
269
|
+
id="test"
|
|
270
|
+
name="test"
|
|
271
|
+
options={[]}
|
|
272
|
+
initialValue={[{ label: 'Chip' }]}
|
|
273
|
+
disabled
|
|
274
|
+
onChange={() => {}}
|
|
275
|
+
/>,
|
|
276
|
+
);
|
|
277
|
+
expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('keeps chips but does not let them be removed', () => {
|
|
281
|
+
render(
|
|
282
|
+
<Typeahead
|
|
283
|
+
id="test"
|
|
284
|
+
name="test"
|
|
285
|
+
options={[]}
|
|
286
|
+
multiple
|
|
287
|
+
initialValue={[{ label: 'Chip' }]}
|
|
288
|
+
disabled
|
|
289
|
+
onChange={() => {}}
|
|
290
|
+
/>,
|
|
291
|
+
);
|
|
292
|
+
expect(screen.getByText('Chip').closest('.np-chip')).toHaveClass('disabled');
|
|
293
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('does not open the suggestions when the input is clicked', async () => {
|
|
297
|
+
render(
|
|
298
|
+
<Typeahead
|
|
299
|
+
id="test"
|
|
300
|
+
name="test"
|
|
301
|
+
options={[{ label: 'Option 1' }]}
|
|
302
|
+
minQueryLength={0}
|
|
303
|
+
disabled
|
|
304
|
+
onChange={() => {}}
|
|
305
|
+
/>,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
await userEvent.click(screen.getByRole('combobox'));
|
|
309
|
+
|
|
310
|
+
expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('closes the open menu when it becomes disabled', async () => {
|
|
314
|
+
const onChange = jest.fn();
|
|
315
|
+
const props = {
|
|
316
|
+
id: 'test',
|
|
317
|
+
name: 'test',
|
|
318
|
+
options: [],
|
|
319
|
+
allowNew: true,
|
|
320
|
+
minQueryLength: 0,
|
|
321
|
+
onChange,
|
|
322
|
+
};
|
|
323
|
+
const { rerender } = render(<Typeahead {...props} />);
|
|
324
|
+
|
|
325
|
+
await userEvent.type(screen.getByRole('combobox'), 'New thing');
|
|
326
|
+
rerender(<Typeahead {...props} disabled />);
|
|
327
|
+
await userEvent.click(document.body);
|
|
328
|
+
|
|
329
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
259
332
|
});
|
|
@@ -61,6 +61,8 @@ export interface TypeaheadProps<T> extends Partial<WrappedComponentProps> {
|
|
|
61
61
|
chipSeparators?: readonly string[];
|
|
62
62
|
/** @default true */
|
|
63
63
|
clearable?: boolean;
|
|
64
|
+
/** @default false */
|
|
65
|
+
disabled?: boolean;
|
|
64
66
|
footer?: ReactNode;
|
|
65
67
|
/** @default [] */
|
|
66
68
|
initialValue?: readonly TypeaheadOption<T>[];
|
|
@@ -112,6 +114,7 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
|
|
|
112
114
|
autoFocus: false,
|
|
113
115
|
chipSeparators: [],
|
|
114
116
|
clearable: true,
|
|
117
|
+
disabled: false,
|
|
115
118
|
initialValue: [],
|
|
116
119
|
inputAutoComplete: 'new-password',
|
|
117
120
|
minQueryLength: DEFAULT_MIN_QUERY_LENGTH,
|
|
@@ -160,6 +163,12 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
|
|
|
160
163
|
}
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
componentDidUpdate(previousProps: TypeaheadPropsWithInputAttributes<T>) {
|
|
167
|
+
if (this.props.disabled && !previousProps.disabled) {
|
|
168
|
+
this.hideMenu();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
163
172
|
componentWillUnmount() {
|
|
164
173
|
this.handleSearchDebounced.cancel();
|
|
165
174
|
}
|
|
@@ -398,6 +407,7 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
|
|
|
398
407
|
};
|
|
399
408
|
|
|
400
409
|
renderChip = (option: TypeaheadOption<T>, idx: number): ReactNode => {
|
|
410
|
+
const { disabled } = this.props;
|
|
401
411
|
const valid = this.props.validateChip?.(option);
|
|
402
412
|
|
|
403
413
|
return (
|
|
@@ -408,7 +418,8 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
|
|
|
408
418
|
'has-error': !valid,
|
|
409
419
|
'np-chip--valid': valid,
|
|
410
420
|
})}
|
|
411
|
-
|
|
421
|
+
disabled={disabled}
|
|
422
|
+
onRemove={disabled ? undefined : () => this.removeChip(option)}
|
|
412
423
|
/>
|
|
413
424
|
);
|
|
414
425
|
};
|
|
@@ -486,6 +497,7 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
|
|
|
486
497
|
addon,
|
|
487
498
|
name,
|
|
488
499
|
clearable,
|
|
500
|
+
disabled,
|
|
489
501
|
allowNew,
|
|
490
502
|
footer,
|
|
491
503
|
showSuggestions,
|
|
@@ -501,9 +513,10 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
|
|
|
501
513
|
|
|
502
514
|
const { errorState, query, selected, optionsShown, keyboardFocusedOptionIndex } = this.state;
|
|
503
515
|
|
|
504
|
-
const clearButton = clearable && (query || selected.length > 0);
|
|
516
|
+
const clearButton = !disabled && clearable && (query || selected.length > 0);
|
|
505
517
|
|
|
506
|
-
const dropdownOpen =
|
|
518
|
+
const dropdownOpen =
|
|
519
|
+
!disabled && optionsShown && showSuggestions && query.length >= minQueryLength;
|
|
507
520
|
|
|
508
521
|
const menu = this.renderMenu({
|
|
509
522
|
footer,
|
|
@@ -556,6 +569,7 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
|
|
|
556
569
|
<div
|
|
557
570
|
className={clsx(`input-group input-group-${size}`, {
|
|
558
571
|
'input-group--has-error': hasError,
|
|
572
|
+
disabled,
|
|
559
573
|
})}
|
|
560
574
|
>
|
|
561
575
|
{addon && <span className="input-group-addon input-group-addon--search">{addon}</span>}
|
|
@@ -563,6 +577,7 @@ class Typeahead<T> extends Component<TypeaheadPropsWithInputAttributes<T>, Typea
|
|
|
563
577
|
<TypeaheadInput
|
|
564
578
|
{...{
|
|
565
579
|
autoFocus,
|
|
580
|
+
disabled,
|
|
566
581
|
multiple,
|
|
567
582
|
dropdownOpen,
|
|
568
583
|
placeholder,
|
|
@@ -23,7 +23,10 @@ export type TypeaheadInputProps<T> = {
|
|
|
23
23
|
onPaste: React.ClipboardEventHandler<HTMLInputElement>;
|
|
24
24
|
renderChip: (chip: TypeaheadOption<T>, index: number) => ReactNode;
|
|
25
25
|
ariaActivedescendant?: string;
|
|
26
|
-
} & Pick<
|
|
26
|
+
} & Pick<
|
|
27
|
+
TypeaheadProps<T>,
|
|
28
|
+
'id' | 'name' | 'autoFocus' | 'disabled' | 'multiple' | 'placeholder' | 'maxHeight'
|
|
29
|
+
>;
|
|
27
30
|
|
|
28
31
|
type TypeaheadInputState = {
|
|
29
32
|
inputWidth: number;
|
|
@@ -73,6 +76,7 @@ export default class TypeaheadInput<T> extends Component<
|
|
|
73
76
|
const {
|
|
74
77
|
typeaheadId,
|
|
75
78
|
autoFocus,
|
|
79
|
+
disabled,
|
|
76
80
|
multiple,
|
|
77
81
|
name,
|
|
78
82
|
dropdownOpen,
|
|
@@ -98,6 +102,7 @@ export default class TypeaheadInput<T> extends Component<
|
|
|
98
102
|
id={`input-${typeaheadId}`}
|
|
99
103
|
aria-activedescendant={ariaActivedescendant}
|
|
100
104
|
autoFocus={autoFocus}
|
|
105
|
+
disabled={disabled}
|
|
101
106
|
placeholder={hasPlaceholder ? placeholder : ''}
|
|
102
107
|
aria-autocomplete="list"
|
|
103
108
|
aria-expanded={dropdownOpen}
|
|
@@ -3,6 +3,7 @@ import { Search as SearchIcon } from '@transferwise/icons';
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import Button from '../../Button';
|
|
6
|
+
import Field from '../../Field/Field';
|
|
6
7
|
import Modal from '../../Modal';
|
|
7
8
|
import Typeahead from '../Typeahead';
|
|
8
9
|
|
|
@@ -17,6 +18,46 @@ const meta: Meta<typeof Typeahead> = {
|
|
|
17
18
|
};
|
|
18
19
|
export default meta;
|
|
19
20
|
|
|
21
|
+
export const Disabled: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<div className="d-flex flex-column">
|
|
24
|
+
<Field id="typeahead-disabled-empty" label="Empty">
|
|
25
|
+
<Typeahead
|
|
26
|
+
id="typeahead-disabled-empty"
|
|
27
|
+
name="typeahead-disabled-empty"
|
|
28
|
+
disabled
|
|
29
|
+
placeholder="Search currencies"
|
|
30
|
+
addon={<SearchIcon size={24} />}
|
|
31
|
+
options={[]}
|
|
32
|
+
onChange={() => {}}
|
|
33
|
+
/>
|
|
34
|
+
</Field>
|
|
35
|
+
<Field id="typeahead-disabled-value" label="With a value">
|
|
36
|
+
<Typeahead
|
|
37
|
+
id="typeahead-disabled-value"
|
|
38
|
+
name="typeahead-disabled-value"
|
|
39
|
+
disabled
|
|
40
|
+
addon={<SearchIcon size={24} />}
|
|
41
|
+
initialValue={[{ label: 'British pound' }]}
|
|
42
|
+
options={[]}
|
|
43
|
+
onChange={() => {}}
|
|
44
|
+
/>
|
|
45
|
+
</Field>
|
|
46
|
+
<Field id="typeahead-disabled-chips" label="With chips">
|
|
47
|
+
<Typeahead
|
|
48
|
+
id="typeahead-disabled-chips"
|
|
49
|
+
name="typeahead-disabled-chips"
|
|
50
|
+
disabled
|
|
51
|
+
multiple
|
|
52
|
+
initialValue={[{ label: 'British pound' }, { label: 'Euro' }]}
|
|
53
|
+
options={[]}
|
|
54
|
+
onChange={() => {}}
|
|
55
|
+
/>
|
|
56
|
+
</Field>
|
|
57
|
+
</div>
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
|
|
20
61
|
export const AutoFocusInModal: Story = {
|
|
21
62
|
parameters: {
|
|
22
63
|
docs: {
|
package/src/i18n/cs.json
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"neptune.PhoneNumberInput.SelectInput.placeholder": "Vybrat možnost...",
|
|
36
36
|
"neptune.PhoneNumberInput.countryCodeLabel": "Kód země",
|
|
37
37
|
"neptune.PhoneNumberInput.phoneNumberLabel": "Telefonní číslo",
|
|
38
|
+
"neptune.PhoneNumberInput.phoneNumberPrefix": "Předčíslí",
|
|
38
39
|
"neptune.Select.searchPlaceholder": "Hledat...",
|
|
39
40
|
"neptune.SelectInput.noResultsFound": "Nebyly nalezeny žádné výsledky",
|
|
40
41
|
"neptune.StatusIcon.iconLabel.error": "Chyba:",
|
package/src/i18n/de.json
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"neptune.PhoneNumberInput.SelectInput.placeholder": "Wähle eine Option...",
|
|
36
36
|
"neptune.PhoneNumberInput.countryCodeLabel": "Ländercode",
|
|
37
37
|
"neptune.PhoneNumberInput.phoneNumberLabel": "Telefonnummer",
|
|
38
|
+
"neptune.PhoneNumberInput.phoneNumberPrefix": "Vorwahl",
|
|
38
39
|
"neptune.Select.searchPlaceholder": "Wird gesucht...",
|
|
39
40
|
"neptune.SelectInput.noResultsFound": "Keine Ergebnisse gefunden",
|
|
40
41
|
"neptune.StatusIcon.iconLabel.error": "Fehler:",
|
package/src/i18n/es.json
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"neptune.PhoneNumberInput.SelectInput.placeholder": "Selecciona una opción...",
|
|
36
36
|
"neptune.PhoneNumberInput.countryCodeLabel": "Código de país",
|
|
37
37
|
"neptune.PhoneNumberInput.phoneNumberLabel": "Número de teléfono",
|
|
38
|
+
"neptune.PhoneNumberInput.phoneNumberPrefix": "Prefijo",
|
|
38
39
|
"neptune.Select.searchPlaceholder": "Buscar...",
|
|
39
40
|
"neptune.SelectInput.noResultsFound": "No se han encontrado resultados",
|
|
40
41
|
"neptune.StatusIcon.iconLabel.error": "Error:",
|