Skip to content

Commit 0f8232f

Browse files
authored
feat: Feature prompt internal component (#4170)
1 parent 15e15bb commit 0f8232f

File tree

10 files changed

+575
-88
lines changed

10 files changed

+575
-88
lines changed

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pages/app/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function isAppLayoutPage(pageId?: string) {
5353
'charts.test',
5454
'error-boundary/demo-async-load',
5555
'error-boundary/demo-components',
56+
'feature-notifications',
5657
];
5758
return pageId !== undefined && appLayoutPages.some(match => pageId.includes(match));
5859
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useEffect, useRef } from 'react';
4+
5+
import { AppLayout, Box, Button, Header, Icon, Link, SpaceBetween } from '~components';
6+
import FeaturePrompt, { FeaturePromptProps } from '~components/internal/do-not-use/feature-prompt';
7+
import { mount, unmount } from '~mount';
8+
9+
import { Breadcrumbs, Containers, Navigation, Tools } from '../app-layout/utils/content-blocks';
10+
import labels from '../app-layout/utils/labels';
11+
import * as toolsContent from '../app-layout/utils/tools-content';
12+
import ScreenshotArea from '../utils/screenshot-area';
13+
14+
export default function () {
15+
const featurePromptRef = useRef<FeaturePromptProps.Ref>(null);
16+
17+
useEffect(() => {
18+
const root = document.createElement('div');
19+
document.querySelector('#h a')?.remove();
20+
document.querySelector('#h')?.prepend(root);
21+
22+
mount(
23+
<SpaceBetween direction="horizontal" size="xl">
24+
<Icon name="bug" id="bug-icon" />
25+
<Icon name="settings" id="settings-icon" />
26+
</SpaceBetween>,
27+
root
28+
);
29+
30+
return () => {
31+
unmount(root);
32+
};
33+
}, []);
34+
35+
return (
36+
<ScreenshotArea gutters={false}>
37+
<FeaturePrompt
38+
ref={featurePromptRef}
39+
onDismiss={() => {
40+
// handle focus behavior here
41+
}}
42+
position="bottom"
43+
header={
44+
<Box fontWeight="bold">
45+
<Icon name="gen-ai" /> Our AI buddy is smarter than ever
46+
</Box>
47+
}
48+
content={
49+
<Box>
50+
It supports filtering with plain language, reports generation with .pdf, and so much more! See{' '}
51+
<Link href="#">top 10 things it can do for you</Link>.
52+
</Box>
53+
}
54+
getTrack={() => document.querySelector('#settings-icon')}
55+
/>
56+
<AppLayout
57+
ariaLabels={labels}
58+
breadcrumbs={<Breadcrumbs />}
59+
navigation={<Navigation />}
60+
tools={<Tools>{toolsContent.long}</Tools>}
61+
content={
62+
<>
63+
<div style={{ marginBlockEnd: '1rem' }}>
64+
<Header variant="h1" description="Basic demo">
65+
Demo page
66+
</Header>
67+
</div>
68+
<Button
69+
onClick={() => {
70+
featurePromptRef.current?.show();
71+
}}
72+
>
73+
show a feature prompt
74+
</Button>
75+
<Containers />
76+
</>
77+
}
78+
/>
79+
</ScreenshotArea>
80+
);
81+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import * as React from 'react';
4+
import { useRef } from 'react';
5+
import { fireEvent, render } from '@testing-library/react';
6+
7+
import FeaturePrompt, { FeaturePromptProps } from '../../../../../lib/components/internal/do-not-use/feature-prompt';
8+
import FeaturePromptWrapper from '../../../../../lib/components/test-utils/dom/internal/feature-prompt';
9+
10+
function renderComponent(jsx: React.ReactElement) {
11+
const { container, ...rest } = render(jsx);
12+
const wrapper = new FeaturePromptWrapper(container);
13+
14+
return { wrapper, ...rest };
15+
}
16+
17+
const TestComponent = ({ onDismiss }: { onDismiss?: FeaturePromptProps['onDismiss'] }) => {
18+
const featurePromptRef = useRef<FeaturePromptProps.Ref>(null);
19+
const trackRef = useRef<HTMLDivElement>(null);
20+
21+
return (
22+
<div>
23+
<div ref={trackRef}>tracked element</div>
24+
<button
25+
data-testid="trigger-button"
26+
onClick={() => {
27+
featurePromptRef.current?.show();
28+
}}
29+
>
30+
trigger the feature prompt
31+
</button>
32+
<button
33+
data-testid="dismiss-button"
34+
onClick={() => {
35+
featurePromptRef.current?.dismiss();
36+
}}
37+
>
38+
dismiss the feature prompt
39+
</button>
40+
<FeaturePrompt
41+
ref={featurePromptRef}
42+
position="left"
43+
header={<div>header</div>}
44+
content={<div>content</div>}
45+
onDismiss={onDismiss}
46+
getTrack={() => trackRef.current}
47+
trackKey="track-element"
48+
/>
49+
</div>
50+
);
51+
};
52+
53+
describe('FeaturePrompt', () => {
54+
test('should render feature prompt only after calling show method', () => {
55+
const { getByTestId, wrapper } = renderComponent(<TestComponent />);
56+
57+
expect(wrapper.findContent()).toBeFalsy();
58+
59+
getByTestId('trigger-button').click();
60+
61+
expect(wrapper.findHeader()!.getElement()).toHaveTextContent('header');
62+
expect(wrapper.findContent()!.getElement()).toHaveTextContent('content');
63+
});
64+
65+
test('should dismiss feature prompt on shifting focus away', () => {
66+
const { getByTestId, wrapper } = renderComponent(<TestComponent />);
67+
68+
expect(wrapper.findContent()).toBeFalsy();
69+
70+
getByTestId('trigger-button').click();
71+
72+
expect(wrapper.findHeader()!.getElement()).toHaveTextContent('header');
73+
expect(wrapper.findContent()!.getElement()).toHaveTextContent('content');
74+
75+
fireEvent.blur(wrapper.findContent()!.getElement());
76+
expect(wrapper.findContent()).toBeFalsy();
77+
});
78+
79+
test('should call component onDismiss when dismissed via close button', () => {
80+
const onDismissMock = jest.fn();
81+
const { getByTestId, wrapper } = renderComponent(<TestComponent onDismiss={onDismissMock} />);
82+
83+
getByTestId('trigger-button').click();
84+
expect(wrapper.findContent()).toBeTruthy();
85+
86+
wrapper.findDismissButton()!.click();
87+
88+
expect(onDismissMock).toHaveBeenCalledTimes(1);
89+
expect(wrapper.findContent()).toBeFalsy();
90+
});
91+
92+
test('should call component onDismiss when dismissed programmatically', () => {
93+
const onDismissMock = jest.fn();
94+
const { getByTestId, wrapper } = renderComponent(<TestComponent onDismiss={onDismissMock} />);
95+
96+
getByTestId('trigger-button').click();
97+
expect(wrapper.findContent()).toBeTruthy();
98+
99+
getByTestId('dismiss-button').click();
100+
101+
expect(onDismissMock).toHaveBeenCalledTimes(1);
102+
expect(wrapper.findContent()).toBeFalsy();
103+
});
104+
105+
test('should call component onDismiss when dismissed via blur', () => {
106+
const onDismissMock = jest.fn();
107+
const { getByTestId, wrapper } = renderComponent(<TestComponent onDismiss={onDismissMock} />);
108+
109+
getByTestId('trigger-button').click();
110+
expect(wrapper.findContent()).toBeTruthy();
111+
112+
fireEvent.blur(wrapper.findContent()!.getElement());
113+
114+
expect(onDismissMock).toHaveBeenCalledTimes(1);
115+
expect(wrapper.findContent()).toBeFalsy();
116+
});
117+
118+
test('should not dismiss when blur relatedTarget is within popover body', () => {
119+
const onDismissMock = jest.fn();
120+
const { getByTestId, wrapper } = renderComponent(<TestComponent onDismiss={onDismissMock} />);
121+
122+
getByTestId('trigger-button').click();
123+
expect(wrapper.findContent()).toBeTruthy();
124+
125+
const popoverBody = wrapper.findContent()!.getElement();
126+
const dismissButton = wrapper.findDismissButton()!.getElement();
127+
128+
fireEvent.blur(popoverBody, {
129+
relatedTarget: dismissButton,
130+
});
131+
132+
expect(onDismissMock).not.toHaveBeenCalled();
133+
expect(wrapper.findContent()).toBeTruthy();
134+
});
135+
136+
test('should dismiss when blur relatedTarget is outside popover body', () => {
137+
const onDismissMock = jest.fn();
138+
const { getByTestId, wrapper } = renderComponent(<TestComponent onDismiss={onDismissMock} />);
139+
140+
getByTestId('trigger-button').click();
141+
expect(wrapper.findContent()).toBeTruthy();
142+
143+
const popoverBody = wrapper.findContent()!.getElement();
144+
const triggerButton = getByTestId('trigger-button');
145+
146+
fireEvent.blur(popoverBody, {
147+
relatedTarget: triggerButton,
148+
});
149+
150+
expect(onDismissMock).toHaveBeenCalledTimes(1);
151+
expect(wrapper.findContent()).toBeFalsy();
152+
});
153+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React from 'react';
5+
6+
import useBaseComponent from '../../hooks/use-base-component';
7+
import { applyDisplayName } from '../../utils/apply-display-name';
8+
import { getExternalProps } from '../../utils/external-props';
9+
import { FeaturePromptProps } from './interfaces';
10+
import InternalFeaturePrompt from './internal';
11+
12+
export { FeaturePromptProps };
13+
14+
const FeaturePrompt = React.forwardRef(
15+
(
16+
{ size = 'medium', position = 'top', ...rest }: FeaturePromptProps,
17+
ref: React.Ref<FeaturePromptProps.Ref>
18+
): JSX.Element => {
19+
const baseComponentProps = useBaseComponent('FeaturePrompt', { props: { size, position } });
20+
21+
const externalProps = getExternalProps(rest);
22+
return (
23+
<InternalFeaturePrompt ref={ref} size={size} position={position} {...externalProps} {...baseComponentProps} />
24+
);
25+
}
26+
);
27+
28+
applyDisplayName(FeaturePrompt, 'FeaturePrompt');
29+
export default FeaturePrompt;
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { PopoverProps } from '../../../popover/interfaces';
5+
import { NonCancelableEventHandler } from '../../events';
6+
7+
export interface FeaturePromptProps {
8+
/**
9+
* Called when the feature prompt shows.
10+
*/
11+
onShow?: NonCancelableEventHandler<null>;
12+
13+
/**
14+
* Called when a user closes the prompt by using the close icon button,
15+
* clicking outside the prompt, shifting focus out of the prompt or pressing ESC.
16+
*/
17+
onDismiss?: NonCancelableEventHandler<null>;
18+
19+
/**
20+
* Determines where the feature prompt is displayed when opened, relative to the trigger.
21+
* If the feature prompt doesn't have enough space to open in this direction, it
22+
* automatically chooses a better direction based on available space.
23+
*/
24+
position?: FeaturePromptProps.Position;
25+
26+
/**
27+
* Determines the maximum width for the feature prompt.
28+
*/
29+
size?: FeaturePromptProps.Size;
30+
31+
/**
32+
* Specifies header content for the feature prompt.
33+
*/
34+
header: React.ReactNode;
35+
36+
/**
37+
* Content of the feature prompt.
38+
*/
39+
content: React.ReactNode;
40+
41+
/**
42+
* An object containing all the necessary localized strings required by the component.
43+
* @i18n
44+
*/
45+
i18nStrings?: FeaturePromptProps.I18nStrings;
46+
47+
/**
48+
* Function that returns the element to track for positioning the prompt.
49+
* Use this when you want to position the prompt relative to an external element.
50+
* Cannot be used together with the children prop.
51+
*/
52+
getTrack: () => null | HTMLElement | SVGElement;
53+
54+
/**
55+
* Unique identifier for the tracked element. Used for tracking position changes
56+
* when using getTrack.
57+
*/
58+
trackKey?: string | number;
59+
}
60+
61+
export namespace FeaturePromptProps {
62+
export type Position = PopoverProps.Position;
63+
export type Size = PopoverProps.Size;
64+
export interface I18nStrings {
65+
/**
66+
* Adds an `aria-label` to the dismiss button for accessibility.
67+
* @i18n
68+
*/
69+
dismissAriaLabel?: string;
70+
}
71+
export interface Ref {
72+
/**
73+
* Use only if an element other than the trigger needs to be focused after dismissing the prompt.
74+
*/
75+
dismiss(): void;
76+
77+
/**
78+
* Shows the prompt and focuses its close button.
79+
*/
80+
show(): void;
81+
}
82+
}

0 commit comments

Comments
 (0)