@transferwise/components 46.157.1 → 46.159.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/FlowNavigation/FlowNavigation.js +5 -14
- package/build/FlowNavigation/FlowNavigation.js.map +1 -1
- package/build/FlowNavigation/FlowNavigation.mjs +5 -14
- package/build/FlowNavigation/FlowNavigation.mjs.map +1 -1
- package/build/Markup/Markup.js +15 -14
- package/build/Markup/Markup.js.map +1 -1
- package/build/Markup/Markup.mjs +15 -14
- package/build/Markup/Markup.mjs.map +1 -1
- package/build/Popover/Popover.js +4 -0
- package/build/Popover/Popover.js.map +1 -1
- package/build/Popover/Popover.mjs +4 -0
- package/build/Popover/Popover.mjs.map +1 -1
- package/build/_deprecated/OverlayHeader/OverlayHeader.js +6 -41
- package/build/_deprecated/OverlayHeader/OverlayHeader.js.map +1 -1
- package/build/_deprecated/OverlayHeader/OverlayHeader.mjs +6 -41
- package/build/_deprecated/OverlayHeader/OverlayHeader.mjs.map +1 -1
- package/build/common/Panel/Panel.js +13 -4
- package/build/common/Panel/Panel.js.map +1 -1
- package/build/common/Panel/Panel.mjs +14 -5
- package/build/common/Panel/Panel.mjs.map +1 -1
- package/build/common/PropsValues/position.js +29 -0
- package/build/common/PropsValues/position.js.map +1 -1
- package/build/common/PropsValues/position.mjs +28 -1
- package/build/common/PropsValues/position.mjs.map +1 -1
- package/build/main.css +8 -34
- package/build/styles/FlowNavigation/FlowNavigation.css +7 -13
- package/build/styles/main.css +8 -34
- package/build/types/FlowNavigation/FlowNavigation.d.ts +2 -9
- package/build/types/FlowNavigation/FlowNavigation.d.ts.map +1 -1
- package/build/types/Markup/Markup.d.ts.map +1 -1
- package/build/types/Markup/Markup.types.d.ts +16 -5
- package/build/types/Markup/Markup.types.d.ts.map +1 -1
- package/build/types/Markup/index.d.ts +1 -1
- package/build/types/Markup/index.d.ts.map +1 -1
- package/build/types/Popover/Popover.d.ts +1 -1
- package/build/types/Popover/Popover.d.ts.map +1 -1
- package/build/types/_deprecated/OverlayHeader/OverlayHeader.d.ts.map +1 -1
- package/build/types/common/Panel/Panel.d.ts +6 -6
- package/build/types/common/Panel/Panel.d.ts.map +1 -1
- package/build/types/common/PropsValues/position.d.ts +18 -1
- package/build/types/common/PropsValues/position.d.ts.map +1 -1
- package/build/types/common/ResponsivePanel/ResponsivePanel.d.ts +2 -2
- package/build/types/index.d.ts +1 -1
- package/build/types/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/FlowNavigation/FlowNavigation.css +7 -13
- package/src/FlowNavigation/FlowNavigation.less +6 -15
- package/src/FlowNavigation/FlowNavigation.tsx +6 -27
- package/src/FlowNavigation/_stories/FlowNavigation.story.tsx +0 -86
- package/src/Markup/Markup.test.tsx +8 -7
- package/src/Markup/Markup.tsx +18 -15
- package/src/Markup/Markup.types.ts +16 -6
- package/src/Markup/_stories/Markup.docs.mdx +60 -0
- package/src/Markup/_stories/Markup.story.tsx +7 -8
- package/src/Markup/index.ts +6 -1
- package/src/Popover/Popover.tsx +9 -2
- package/src/_deprecated/OverlayHeader/OverlayHeader.tsx +2 -21
- package/src/common/Panel/Panel.test.tsx +17 -0
- package/src/common/Panel/Panel.tsx +17 -8
- package/src/common/Panel/_stories/Panel.story.tsx +182 -0
- package/src/common/PropsValues/position.ts +54 -0
- package/src/i18n/en.json +1 -0
- package/src/index.ts +1 -1
- package/src/main.css +8 -34
- package/src/main.less +0 -1
- package/build/styles/_deprecated/OverlayHeader/OverlayHeader.css +0 -15
- package/src/_deprecated/OverlayHeader/OverlayHeader.css +0 -15
- package/src/_deprecated/OverlayHeader/OverlayHeader.less +0 -24
package/src/Markup/Markup.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import Link from '../Link';
|
|
|
4
4
|
|
|
5
5
|
import { parseMarkup } from './utils/parseMarkup';
|
|
6
6
|
import { type AllowLinksMode, isHrefAllowedByMode, sanitiseHref } from './utils/sanitise';
|
|
7
|
-
import type {
|
|
7
|
+
import type { MarkupActionHandler, MarkupNode, MarkupProps } from './Markup.types';
|
|
8
8
|
|
|
9
9
|
const PLACEHOLDER_REGEX = /\{\{(\w+)\}\}/g;
|
|
10
10
|
|
|
@@ -16,28 +16,31 @@ function substituteData(text: string, data: MarkupData): string {
|
|
|
16
16
|
);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
type OnAction = MarkupActionHandler | undefined;
|
|
20
|
+
|
|
19
21
|
function renderNodes(
|
|
20
22
|
nodes: MarkupNode[],
|
|
21
|
-
|
|
23
|
+
onAction: OnAction,
|
|
22
24
|
data: MarkupData | undefined,
|
|
23
25
|
allowLinks: AllowLinksMode,
|
|
24
26
|
): React.ReactNode[] {
|
|
25
|
-
return nodes.map((node, index) => renderNode(node,
|
|
27
|
+
return nodes.map((node, index) => renderNode(node, onAction, data, allowLinks, index));
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
function renderLinkNode(
|
|
29
31
|
node: Extract<MarkupNode, { type: 'link' }>,
|
|
30
|
-
|
|
32
|
+
onAction: OnAction,
|
|
31
33
|
data: MarkupData | undefined,
|
|
32
34
|
allowLinks: AllowLinksMode,
|
|
33
35
|
key: number,
|
|
34
36
|
): React.ReactNode {
|
|
35
|
-
const children = renderNodes(node.children,
|
|
37
|
+
const children = renderNodes(node.children, onAction, data, allowLinks);
|
|
36
38
|
|
|
37
|
-
if (node.action &&
|
|
39
|
+
if (node.action && onAction) {
|
|
40
|
+
const actionId = node.action;
|
|
38
41
|
return (
|
|
39
42
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
|
40
|
-
<Link key={key} aria-label={node.accessibilityLabel} onClick={
|
|
43
|
+
<Link key={key} aria-label={node.accessibilityLabel} onClick={() => onAction(actionId)}>
|
|
41
44
|
{children}
|
|
42
45
|
</Link>
|
|
43
46
|
);
|
|
@@ -62,7 +65,7 @@ function renderLinkNode(
|
|
|
62
65
|
|
|
63
66
|
function renderNode(
|
|
64
67
|
node: MarkupNode,
|
|
65
|
-
|
|
68
|
+
onAction: OnAction,
|
|
66
69
|
data: MarkupData | undefined,
|
|
67
70
|
allowLinks: AllowLinksMode,
|
|
68
71
|
key: number,
|
|
@@ -83,29 +86,29 @@ function renderNode(
|
|
|
83
86
|
className={`wds-markup-emphasis wds-markup-emphasis--${node.type}`}
|
|
84
87
|
aria-label={node.accessibilityLabel}
|
|
85
88
|
>
|
|
86
|
-
{renderNodes(node.children,
|
|
89
|
+
{renderNodes(node.children, onAction, data, allowLinks)}
|
|
87
90
|
</em>
|
|
88
91
|
);
|
|
89
92
|
case 'strong':
|
|
90
93
|
return (
|
|
91
94
|
<strong key={key} className="wds-markup-strong" aria-label={node.accessibilityLabel}>
|
|
92
|
-
{renderNodes(node.children,
|
|
95
|
+
{renderNodes(node.children, onAction, data, allowLinks)}
|
|
93
96
|
</strong>
|
|
94
97
|
);
|
|
95
98
|
case 'strikethrough':
|
|
96
99
|
return (
|
|
97
100
|
<del key={key} className="wds-markup-strikethrough" aria-label={node.accessibilityLabel}>
|
|
98
|
-
{renderNodes(node.children,
|
|
101
|
+
{renderNodes(node.children, onAction, data, allowLinks)}
|
|
99
102
|
</del>
|
|
100
103
|
);
|
|
101
104
|
case 'paragraph':
|
|
102
105
|
return (
|
|
103
106
|
<p key={key} className="wds-markup-paragraph">
|
|
104
|
-
{renderNodes(node.children,
|
|
107
|
+
{renderNodes(node.children, onAction, data, allowLinks)}
|
|
105
108
|
</p>
|
|
106
109
|
);
|
|
107
110
|
case 'link':
|
|
108
|
-
return renderLinkNode(node,
|
|
111
|
+
return renderLinkNode(node, onAction, data, allowLinks, key);
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
|
|
@@ -138,7 +141,7 @@ export const Markup: React.FC<MarkupProps> = ({
|
|
|
138
141
|
children,
|
|
139
142
|
data,
|
|
140
143
|
allowLinks = 'internal',
|
|
141
|
-
|
|
144
|
+
onAction,
|
|
142
145
|
as,
|
|
143
146
|
className,
|
|
144
147
|
'data-testid': testId,
|
|
@@ -167,7 +170,7 @@ export const Markup: React.FC<MarkupProps> = ({
|
|
|
167
170
|
|
|
168
171
|
return (
|
|
169
172
|
<Element className={className} data-testid={testId}>
|
|
170
|
-
{renderNodes(tree,
|
|
173
|
+
{renderNodes(tree, onAction, data, allowLinks)}
|
|
171
174
|
</Element>
|
|
172
175
|
);
|
|
173
176
|
};
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
|
-
import type { LinkProps } from '../Link';
|
|
4
|
-
|
|
5
3
|
export type MarkupNode =
|
|
6
4
|
| { type: 'text'; content: string }
|
|
7
5
|
| { type: 'newline' }
|
|
@@ -20,9 +18,20 @@ export type MarkupNode =
|
|
|
20
18
|
children: MarkupNode[];
|
|
21
19
|
};
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Callback signature for the `onAction` prop. Receives the action name from
|
|
23
|
+
* `<link action="name">` when clicked.
|
|
24
|
+
*/
|
|
25
|
+
export type MarkupActionHandler = (actionId: string) => void;
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Per-prop Markup configuration that host components can accept via a `markupOptions`
|
|
29
|
+
* prop keyed by their text prop names (e.g. `{ title?: MarkupTextOptions; description?: MarkupTextOptions }`).
|
|
30
|
+
*/
|
|
31
|
+
export type MarkupTextOptions = {
|
|
32
|
+
data?: Record<string, string | number>;
|
|
33
|
+
allowLinks?: 'internal' | 'all' | 'none';
|
|
34
|
+
};
|
|
26
35
|
|
|
27
36
|
export interface MarkupProps {
|
|
28
37
|
/**
|
|
@@ -45,9 +54,10 @@ export interface MarkupProps {
|
|
|
45
54
|
* */
|
|
46
55
|
allowLinks?: 'internal' | 'all' | 'none';
|
|
47
56
|
/**
|
|
48
|
-
*
|
|
57
|
+
* Callback invoked when a `<link action="name">` button-mode link is clicked.
|
|
58
|
+
* Receives the action name as argument.
|
|
49
59
|
* */
|
|
50
|
-
|
|
60
|
+
onAction?: MarkupActionHandler;
|
|
51
61
|
/**
|
|
52
62
|
* Wrapper element. Defaults to `div` when the parsed tree contains `<paragraph>` nodes,
|
|
53
63
|
* `span` otherwise. Set explicitly to override auto-detection.
|
|
@@ -4,6 +4,66 @@ import { Meta, Source } from '@storybook/addon-docs/blocks';
|
|
|
4
4
|
|
|
5
5
|
# Developer notes
|
|
6
6
|
|
|
7
|
+
## Integrating Markup in host components
|
|
8
|
+
|
|
9
|
+
To integrate any component with Markup (e.g. `title`, `description`), follow this pattern:
|
|
10
|
+
|
|
11
|
+
1. **Add `onMarkupAction`** — if the component is expected to accept actions, add a single callback prop that the
|
|
12
|
+
component forwards to every internal `<Markup onAction={onMarkupAction}>` instance. The name of this prop must
|
|
13
|
+
remain consistent across components.
|
|
14
|
+
|
|
15
|
+
2. **Add `markupOptions`** — an optional prop keyed by the text prop names, carrying per-prop Markup configuration
|
|
16
|
+
(`data`, `allowLinks`). `MarkupTextOptions` type is exposed by the component.
|
|
17
|
+
|
|
18
|
+
<Source dark language="tsx" code={`
|
|
19
|
+
import Markup, { type MarkupActionHandler, type MarkupTextOptions } from '../Markup';
|
|
20
|
+
|
|
21
|
+
type MyComponentProps = {
|
|
22
|
+
title: ReactNode;
|
|
23
|
+
description?: ReactNode;
|
|
24
|
+
onMarkupAction?: MarkupActionHandler;
|
|
25
|
+
markupOptions?: {
|
|
26
|
+
title?: MarkupTextOptions;
|
|
27
|
+
description?: MarkupTextOptions;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function MyComponent({ title, description, onMarkupAction, markupOptions }: MyComponentProps) {
|
|
32
|
+
return (
|
|
33
|
+
<div>
|
|
34
|
+
<Markup onAction={onMarkupAction} {...markupOptions?.title}>
|
|
35
|
+
{title}
|
|
36
|
+
</Markup>
|
|
37
|
+
|
|
38
|
+
{description && (
|
|
39
|
+
<Markup onAction={onMarkupAction} {...markupOptions?.description}>
|
|
40
|
+
{description}
|
|
41
|
+
</Markup>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
`} />
|
|
47
|
+
|
|
48
|
+
Consumer usage:
|
|
49
|
+
|
|
50
|
+
<Source
|
|
51
|
+
dark
|
|
52
|
+
language="tsx"
|
|
53
|
+
code={`
|
|
54
|
+
<MyComponent
|
|
55
|
+
title='Something went wrong. <link action="retry">Try again</link>.'
|
|
56
|
+
description='Your balance is {{amount}}.'
|
|
57
|
+
onMarkupAction={(actionId) => {
|
|
58
|
+
if (actionId === 'retry') refetch();
|
|
59
|
+
}}
|
|
60
|
+
markupOptions={{
|
|
61
|
+
description: { data: { amount: '£100.00' }, allowLinks: 'all' }
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
`}
|
|
65
|
+
/>
|
|
66
|
+
|
|
7
67
|
## How parsing works
|
|
8
68
|
|
|
9
69
|
The parser runs in two phases: **tokenise** then **parse**.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
|
3
|
-
import { action } from 'storybook/actions';
|
|
4
3
|
|
|
5
4
|
import {
|
|
6
5
|
createSandboxStory,
|
|
@@ -9,7 +8,6 @@ import {
|
|
|
9
8
|
import { storySourceWithoutNoise } from '../../../.storybook/helpers';
|
|
10
9
|
|
|
11
10
|
import { Markup } from '../Markup';
|
|
12
|
-
import type { MarkupActionHandler } from '../Markup.types';
|
|
13
11
|
|
|
14
12
|
export default {
|
|
15
13
|
component: Markup,
|
|
@@ -58,8 +56,8 @@ export const WithLinks: Story = {
|
|
|
58
56
|
};
|
|
59
57
|
|
|
60
58
|
/**
|
|
61
|
-
* Button-mode link using the `action` attribute. The handler is provided via the `
|
|
62
|
-
* If
|
|
59
|
+
* Button-mode link using the `action` attribute. The handler is provided via the `onAction` prop.
|
|
60
|
+
* If `onAction` is not provided, the link degrades to plain text.
|
|
63
61
|
* */
|
|
64
62
|
export const WithLinksAsButtons = storySourceWithoutNoise<Story>({
|
|
65
63
|
args: {
|
|
@@ -68,14 +66,15 @@ export const WithLinksAsButtons = storySourceWithoutNoise<Story>({
|
|
|
68
66
|
render: function Render(args) {
|
|
69
67
|
const [count, setCount] = useState(0);
|
|
70
68
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
const handleAction = (actionId: string) => {
|
|
70
|
+
if (actionId === 'increment') {
|
|
71
|
+
setCount((prev) => prev + 1);
|
|
72
|
+
}
|
|
74
73
|
};
|
|
75
74
|
|
|
76
75
|
return (
|
|
77
76
|
<>
|
|
78
|
-
<Markup
|
|
77
|
+
<Markup onAction={handleAction}>{args.children}</Markup>
|
|
79
78
|
<output>{count}</output>
|
|
80
79
|
</>
|
|
81
80
|
);
|
package/src/Markup/index.ts
CHANGED
package/src/Popover/Popover.tsx
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { useTheme } from '@wise/components-theming';
|
|
2
1
|
import { clsx } from 'clsx';
|
|
3
2
|
import { useRef, useState, cloneElement, useEffect, isValidElement, useId } from 'react';
|
|
4
3
|
|
|
@@ -9,10 +8,14 @@ import { logActionRequired } from '../utilities';
|
|
|
9
8
|
|
|
10
9
|
/** @deprecated Use `"top" | "bottom"` instead. */
|
|
11
10
|
type PopoverPreferredPlacementDeprecated =
|
|
11
|
+
| `${Position.TOP_LEFT}`
|
|
12
|
+
| `${Position.TOP_RIGHT}`
|
|
12
13
|
| `${Position.LEFT_TOP}`
|
|
13
14
|
| `${Position.RIGHT_TOP}`
|
|
14
15
|
| `${Position.BOTTOM_RIGHT}`
|
|
15
|
-
| `${Position.BOTTOM_LEFT}
|
|
16
|
+
| `${Position.BOTTOM_LEFT}`
|
|
17
|
+
| `${Position.LEFT_BOTTOM}`
|
|
18
|
+
| `${Position.RIGHT_BOTTOM}`;
|
|
16
19
|
|
|
17
20
|
export type PopoverPreferredPlacement =
|
|
18
21
|
| `${Position.TOP}`
|
|
@@ -35,11 +38,15 @@ export interface PopoverProps {
|
|
|
35
38
|
|
|
36
39
|
function resolvePlacement(preferredPlacement: PopoverPreferredPlacement) {
|
|
37
40
|
switch (preferredPlacement) {
|
|
41
|
+
case 'top-left':
|
|
42
|
+
case 'top-right':
|
|
38
43
|
case 'left-top':
|
|
39
44
|
case 'right-top':
|
|
40
45
|
return 'top';
|
|
41
46
|
case 'bottom-left':
|
|
42
47
|
case 'bottom-right':
|
|
48
|
+
case 'left-bottom':
|
|
49
|
+
case 'right-bottom':
|
|
43
50
|
return 'bottom';
|
|
44
51
|
default:
|
|
45
52
|
return preferredPlacement;
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import { clsx } from 'clsx';
|
|
2
|
-
|
|
3
|
-
import { Size } from '../../common';
|
|
4
|
-
import { CloseButton } from '../../common/CloseButton';
|
|
5
|
-
import FlowHeader from '../../common/FlowHeader';
|
|
6
1
|
import Logo from '../../Logo';
|
|
2
|
+
import FlowNavigation from '../../FlowNavigation';
|
|
7
3
|
|
|
8
4
|
/**
|
|
9
5
|
* @deprecated Use `FlowNavigation` component instead, with `steps={[]}`
|
|
@@ -22,20 +18,5 @@ const defaultLogo = <Logo />;
|
|
|
22
18
|
* @deprecated Use `FlowNavigation` component instead
|
|
23
19
|
*/
|
|
24
20
|
export default function OverlayHeader({ avatar, onClose, logo = defaultLogo }: OverlayHeaderProps) {
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<div className="np-overlay-header d-flex justify-content-center">
|
|
28
|
-
<FlowHeader
|
|
29
|
-
className="np-overlay-header__content p-a-3"
|
|
30
|
-
leftContent={logo}
|
|
31
|
-
rightContent={
|
|
32
|
-
<div className={clsx('d-flex', 'align-items-center', 'order-2')}>
|
|
33
|
-
{avatar}
|
|
34
|
-
{avatar && closeButton && <span className={clsx('m-x-1')} />}
|
|
35
|
-
{closeButton}
|
|
36
|
-
</div>
|
|
37
|
-
}
|
|
38
|
-
/>
|
|
39
|
-
</div>
|
|
40
|
-
);
|
|
21
|
+
return <FlowNavigation {...{ avatar, onClose, logo }} />;
|
|
41
22
|
}
|
|
@@ -76,4 +76,21 @@ describe('Panel', () => {
|
|
|
76
76
|
|
|
77
77
|
const getPanel = () => document.querySelector('.np-panel--open');
|
|
78
78
|
const getArrow = () => document.querySelector('.np-panel__arrow');
|
|
79
|
+
|
|
80
|
+
describe('aligned placements', () => {
|
|
81
|
+
it.each<[PanelProps['position'], string]>([
|
|
82
|
+
[Position.TOP_LEFT, 'top-start'],
|
|
83
|
+
[Position.TOP_RIGHT, 'top-end'],
|
|
84
|
+
[Position.RIGHT_TOP, 'right-start'],
|
|
85
|
+
[Position.RIGHT_BOTTOM, 'right-end'],
|
|
86
|
+
[Position.BOTTOM_RIGHT, 'bottom-end'],
|
|
87
|
+
[Position.BOTTOM_LEFT, 'bottom-start'],
|
|
88
|
+
[Position.LEFT_BOTTOM, 'left-end'],
|
|
89
|
+
[Position.LEFT_TOP, 'left-start'],
|
|
90
|
+
])('converts %s to floating-ui placement %s', (position, expectedPlacement) => {
|
|
91
|
+
render(<Panel {...props} position={position} />);
|
|
92
|
+
|
|
93
|
+
expect(getPanel()).toHaveAttribute('data-popper-placement', expectedPlacement);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
79
96
|
});
|
|
@@ -12,15 +12,15 @@ import { clsx } from 'clsx';
|
|
|
12
12
|
import {
|
|
13
13
|
CSSProperties,
|
|
14
14
|
HTMLAttributes,
|
|
15
|
-
MutableRefObject,
|
|
16
15
|
PropsWithChildren,
|
|
16
|
+
RefObject,
|
|
17
17
|
SyntheticEvent,
|
|
18
18
|
forwardRef,
|
|
19
19
|
useContext,
|
|
20
20
|
useRef,
|
|
21
21
|
} from 'react';
|
|
22
22
|
|
|
23
|
-
import { Position,
|
|
23
|
+
import { Position, type FloatingPlacement, positionToPlacement } from '..';
|
|
24
24
|
import Dimmer from '../../Dimmer';
|
|
25
25
|
import { OverlayIdContext } from '../../Provider/overlay/OverlayIdProvider';
|
|
26
26
|
|
|
@@ -29,11 +29,19 @@ const POPOVER_OFFSET = 16;
|
|
|
29
29
|
// By default the flip positioning explores only the opposite alternative. So if left is passed and there's no enough space
|
|
30
30
|
// the right one gets chosen. If there's no space on both sides popover goes back to the initially chosen one left.
|
|
31
31
|
// This mapping forces popover to try the four available positions before going back to the initial chosen one.
|
|
32
|
-
const fallbackPlacements: Record<
|
|
32
|
+
const fallbackPlacements: Record<Placement, Placement[]> = {
|
|
33
33
|
[Position.TOP]: [Position.BOTTOM, Position.RIGHT, Position.LEFT],
|
|
34
34
|
[Position.BOTTOM]: [Position.TOP, Position.RIGHT, Position.LEFT],
|
|
35
35
|
[Position.LEFT]: [Position.RIGHT, Position.TOP, Position.BOTTOM],
|
|
36
36
|
[Position.RIGHT]: [Position.LEFT, Position.TOP, Position.BOTTOM],
|
|
37
|
+
'top-start': ['bottom-start', 'top-end', 'bottom-end'],
|
|
38
|
+
'top-end': ['bottom-end', 'top-start', 'bottom-start'],
|
|
39
|
+
'bottom-start': ['top-start', 'bottom-end', 'top-end'],
|
|
40
|
+
'bottom-end': ['top-end', 'bottom-start', 'top-start'],
|
|
41
|
+
'left-start': ['right-start', 'left-end', 'right-end'],
|
|
42
|
+
'left-end': ['right-end', 'left-start', 'right-start'],
|
|
43
|
+
'right-start': ['left-start', 'right-end', 'left-end'],
|
|
44
|
+
'right-end': ['left-end', 'right-start', 'left-start'],
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
export type PanelProps = PropsWithChildren<{
|
|
@@ -42,8 +50,8 @@ export type PanelProps = PropsWithChildren<{
|
|
|
42
50
|
altAxis?: boolean;
|
|
43
51
|
open?: boolean;
|
|
44
52
|
onClose?: (event: Event | SyntheticEvent) => void;
|
|
45
|
-
position?:
|
|
46
|
-
anchorRef:
|
|
53
|
+
position?: FloatingPlacement;
|
|
54
|
+
anchorRef: RefObject<Element | null>;
|
|
47
55
|
anchorWidth?: boolean;
|
|
48
56
|
considerHeight?: boolean;
|
|
49
57
|
}> &
|
|
@@ -66,6 +74,7 @@ const Panel = forwardRef<HTMLDivElement, PanelProps>(function Panel(
|
|
|
66
74
|
reference,
|
|
67
75
|
) {
|
|
68
76
|
const arrowRef = useRef<HTMLDivElement | null>(null);
|
|
77
|
+
const floatingPlacement = positionToPlacement(position);
|
|
69
78
|
|
|
70
79
|
const middleware = [];
|
|
71
80
|
|
|
@@ -73,10 +82,10 @@ const Panel = forwardRef<HTMLDivElement, PanelProps>(function Panel(
|
|
|
73
82
|
middleware.push(offset(POPOVER_OFFSET));
|
|
74
83
|
}
|
|
75
84
|
|
|
76
|
-
if (flip && fallbackPlacements[
|
|
85
|
+
if (flip && fallbackPlacements[floatingPlacement]) {
|
|
77
86
|
middleware.push(
|
|
78
87
|
flipMiddleware({
|
|
79
|
-
fallbackPlacements: fallbackPlacements[
|
|
88
|
+
fallbackPlacements: fallbackPlacements[floatingPlacement],
|
|
80
89
|
}),
|
|
81
90
|
);
|
|
82
91
|
}
|
|
@@ -104,7 +113,7 @@ const Panel = forwardRef<HTMLDivElement, PanelProps>(function Panel(
|
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
const { refs, floatingStyles, placement, middlewareData } = useFloating({
|
|
107
|
-
placement:
|
|
116
|
+
placement: floatingPlacement,
|
|
108
117
|
middleware,
|
|
109
118
|
elements: {
|
|
110
119
|
reference: anchorRef.current,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Meta, StoryObj } from '@storybook/react-webpack5';
|
|
2
|
+
import { screen, userEvent, within } from 'storybook/test';
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import Button from '../../../Button';
|
|
6
|
+
import { Position } from '../../PropsValues/position';
|
|
7
|
+
import Panel from '../Panel';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
component: Panel,
|
|
11
|
+
title: 'Dialogs/Panel',
|
|
12
|
+
parameters: {
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component:
|
|
16
|
+
'Panel is a low-level floating component used by Popover and ResponsivePanel. It supports all floating-ui placements.',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
chromatic: {
|
|
20
|
+
delay: 500,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof Panel>;
|
|
24
|
+
|
|
25
|
+
type Story = StoryObj<typeof Panel>;
|
|
26
|
+
|
|
27
|
+
const PanelDemo = ({
|
|
28
|
+
position,
|
|
29
|
+
label,
|
|
30
|
+
}: {
|
|
31
|
+
position: Parameters<typeof Panel>[0]['position'];
|
|
32
|
+
label: string;
|
|
33
|
+
}) => {
|
|
34
|
+
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
35
|
+
const [open, setOpen] = useState(false);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div style={{ display: 'inline-block' }}>
|
|
39
|
+
<Button ref={anchorRef} onClick={() => setOpen((prev) => !prev)}>
|
|
40
|
+
{label}
|
|
41
|
+
</Button>
|
|
42
|
+
<Panel
|
|
43
|
+
open={open}
|
|
44
|
+
position={position}
|
|
45
|
+
anchorRef={anchorRef}
|
|
46
|
+
onClose={() => setOpen(false)}
|
|
47
|
+
aria-label={`Panel positioned ${label}`}
|
|
48
|
+
>
|
|
49
|
+
<div style={{ padding: '16px', maxWidth: '200px' }}>
|
|
50
|
+
Panel content positioned at <strong>{label}</strong>
|
|
51
|
+
</div>
|
|
52
|
+
</Panel>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const AllPositions: Story = {
|
|
58
|
+
parameters: {
|
|
59
|
+
chromatic: { disableSnapshot: true },
|
|
60
|
+
},
|
|
61
|
+
render: () => (
|
|
62
|
+
<div
|
|
63
|
+
style={{
|
|
64
|
+
display: 'flex',
|
|
65
|
+
flexWrap: 'wrap',
|
|
66
|
+
gap: '48px',
|
|
67
|
+
justifyContent: 'center',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
padding: '120px 80px',
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<PanelDemo position={Position.TOP} label="top" />
|
|
73
|
+
<PanelDemo position={Position.TOP_LEFT} label="top-left" />
|
|
74
|
+
<PanelDemo position={Position.TOP_RIGHT} label="top-right" />
|
|
75
|
+
<PanelDemo position={Position.RIGHT} label="right" />
|
|
76
|
+
<PanelDemo position={Position.RIGHT_TOP} label="right-top" />
|
|
77
|
+
<PanelDemo position={Position.RIGHT_BOTTOM} label="right-bottom" />
|
|
78
|
+
<PanelDemo position={Position.BOTTOM} label="bottom" />
|
|
79
|
+
<PanelDemo position={Position.BOTTOM_LEFT} label="bottom-left" />
|
|
80
|
+
<PanelDemo position={Position.BOTTOM_RIGHT} label="bottom-right" />
|
|
81
|
+
<PanelDemo position={Position.LEFT} label="left" />
|
|
82
|
+
<PanelDemo position={Position.LEFT_TOP} label="left-top" />
|
|
83
|
+
<PanelDemo position={Position.LEFT_BOTTOM} label="left-bottom" />
|
|
84
|
+
</div>
|
|
85
|
+
),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const SinglePanelDemo = ({
|
|
89
|
+
position,
|
|
90
|
+
label,
|
|
91
|
+
}: {
|
|
92
|
+
position: Parameters<typeof Panel>[0]['position'];
|
|
93
|
+
label: string;
|
|
94
|
+
}) => {
|
|
95
|
+
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
96
|
+
const [open, setOpen] = useState(false);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div style={{ padding: '120px 250px' }}>
|
|
100
|
+
<Button ref={anchorRef} onClick={() => setOpen((prev) => !prev)}>
|
|
101
|
+
{label}
|
|
102
|
+
</Button>
|
|
103
|
+
<Panel
|
|
104
|
+
open={open}
|
|
105
|
+
position={position}
|
|
106
|
+
anchorRef={anchorRef}
|
|
107
|
+
onClose={() => setOpen(false)}
|
|
108
|
+
aria-label={`Panel positioned ${label}`}
|
|
109
|
+
>
|
|
110
|
+
<div style={{ padding: '16px', maxWidth: '200px' }}>
|
|
111
|
+
Panel content positioned at <strong>{label}</strong>
|
|
112
|
+
</div>
|
|
113
|
+
</Panel>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const openPanelPlay = async ({ canvasElement }: { canvasElement: HTMLElement }) => {
|
|
119
|
+
const canvas = within(canvasElement);
|
|
120
|
+
await userEvent.click(canvas.getByRole('button'));
|
|
121
|
+
await screen.findByText(/Panel content positioned at/i);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const Top: Story = {
|
|
125
|
+
render: () => <SinglePanelDemo position={Position.TOP} label="top" />,
|
|
126
|
+
play: openPanelPlay,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const Right: Story = {
|
|
130
|
+
render: () => <SinglePanelDemo position={Position.RIGHT} label="right" />,
|
|
131
|
+
play: openPanelPlay,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const Bottom: Story = {
|
|
135
|
+
render: () => <SinglePanelDemo position={Position.BOTTOM} label="bottom" />,
|
|
136
|
+
play: openPanelPlay,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const Left: Story = {
|
|
140
|
+
render: () => <SinglePanelDemo position={Position.LEFT} label="left" />,
|
|
141
|
+
play: openPanelPlay,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const TopLeft: Story = {
|
|
145
|
+
render: () => <SinglePanelDemo position={Position.TOP_LEFT} label="top-left" />,
|
|
146
|
+
play: openPanelPlay,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const TopRight: Story = {
|
|
150
|
+
render: () => <SinglePanelDemo position={Position.TOP_RIGHT} label="top-right" />,
|
|
151
|
+
play: openPanelPlay,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const RightTop: Story = {
|
|
155
|
+
render: () => <SinglePanelDemo position={Position.RIGHT_TOP} label="right-top" />,
|
|
156
|
+
play: openPanelPlay,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const RightBottom: Story = {
|
|
160
|
+
render: () => <SinglePanelDemo position={Position.RIGHT_BOTTOM} label="right-bottom" />,
|
|
161
|
+
play: openPanelPlay,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const BottomRight: Story = {
|
|
165
|
+
render: () => <SinglePanelDemo position={Position.BOTTOM_RIGHT} label="bottom-right" />,
|
|
166
|
+
play: openPanelPlay,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const BottomLeft: Story = {
|
|
170
|
+
render: () => <SinglePanelDemo position={Position.BOTTOM_LEFT} label="bottom-left" />,
|
|
171
|
+
play: openPanelPlay,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const LeftBottom: Story = {
|
|
175
|
+
render: () => <SinglePanelDemo position={Position.LEFT_BOTTOM} label="left-bottom" />,
|
|
176
|
+
play: openPanelPlay,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const LeftTop: Story = {
|
|
180
|
+
render: () => <SinglePanelDemo position={Position.LEFT_TOP} label="left-top" />,
|
|
181
|
+
play: openPanelPlay,
|
|
182
|
+
};
|