@simplybusiness/mobius 4.0.0 → 4.1.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/cjs/components/Popover/Popover.d.ts +16 -0
  3. package/dist/cjs/components/Popover/Popover.js +70 -0
  4. package/dist/cjs/components/Popover/Popover.js.map +1 -0
  5. package/dist/cjs/components/Popover/Popover.stories.d.ts +28 -0
  6. package/dist/cjs/components/Popover/Popover.stories.js +43 -0
  7. package/dist/cjs/components/Popover/Popover.stories.js.map +1 -0
  8. package/dist/cjs/components/Popover/Popover.test.d.ts +1 -0
  9. package/dist/cjs/components/Popover/Popover.test.js +105 -0
  10. package/dist/cjs/components/Popover/Popover.test.js.map +1 -0
  11. package/dist/cjs/components/Popover/index.d.ts +1 -0
  12. package/dist/cjs/components/Popover/index.js +18 -0
  13. package/dist/cjs/components/Popover/index.js.map +1 -0
  14. package/dist/cjs/components/index.d.ts +2 -1
  15. package/dist/cjs/components/index.js +2 -1
  16. package/dist/cjs/components/index.js.map +1 -1
  17. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  18. package/dist/esm/components/Popover/Popover.js +63 -0
  19. package/dist/esm/components/Popover/Popover.js.map +1 -0
  20. package/dist/esm/components/Popover/index.js +2 -0
  21. package/dist/esm/components/Popover/index.js.map +1 -0
  22. package/dist/esm/components/index.js +2 -1
  23. package/dist/esm/components/index.js.map +1 -1
  24. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  25. package/dist/mobius.d.ts +18 -0
  26. package/package.json +5 -4
  27. package/src/components/Popover/Popover.mdx +107 -0
  28. package/src/components/Popover/Popover.stories.tsx +105 -0
  29. package/src/components/Popover/Popover.story.styles.css +25 -0
  30. package/src/components/Popover/Popover.test.tsx +186 -0
  31. package/src/components/Popover/Popover.tsx +137 -0
  32. package/src/components/Popover/index.tsx +1 -0
  33. package/src/components/index.tsx +2 -1
@@ -0,0 +1,107 @@
1
+ import { Meta, ArgTypes, Story, Canvas } from "@storybook/addon-docs";
2
+ import * as PopoverStories from "./Popover.stories";
3
+ import { Popover } from "./Popover";
4
+
5
+ <Meta of={PopoverStories} />
6
+
7
+ # Popover
8
+
9
+ The `Popover` component is used to display popup tooltips with additional information for users. Children of the Popover become tooltip contents and `trigger` is a render prop for the element that will present the Popover when clicked.
10
+
11
+ When presented, the Popover will align (vertically) with the trigger element. By default it will appear below.
12
+
13
+ You can dismiss a Popover by clicking the close icon or by pressing `Enter` or `Escape` keys.
14
+
15
+ Focus always remains on the element that triggers the Popover; its content should not accept focus.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ yarn add @simplybusiness/mobius
21
+ ```
22
+
23
+ ## Normal
24
+
25
+ In this example a Button is the trigger and simple text is displayed in the Popover.
26
+
27
+ ```jsx
28
+ import { Popover } from "@simplybusiness/mobius";
29
+
30
+ return (
31
+ <Popover
32
+ trigger={<Button variant="primary">If only I had more information</Button>}
33
+ >
34
+ No way! Now I know everything I need to 🚀.
35
+ </Popover>
36
+ );
37
+ ```
38
+
39
+ <Story of={PopoverStories.Normal} />
40
+
41
+ ## QCP Covers
42
+
43
+ This is a customised trigger as used in the Quote Comparison Page, which has a large, clickable trigger (text and icon), but a small target for aligning the Popover arrow (just the icon).
44
+
45
+ To achieve this, you need to control `isOpen` state with the clickable element and only use Popover to wrap the element you want to align with.
46
+
47
+ ```jsx
48
+ import { Popover, Button, Flex } from "@simplybusiness/mobius";
49
+
50
+ const [isOpen, setIsOpen] = useState<boolean>(false);
51
+ const handleClick = () => setIsOpen(!isOpen);
52
+
53
+ // Component passed to trigger prop must be wrapped with forwardRef()
54
+ const Icon = forwardRef(
55
+ (props: IconProps, ref: Ref<SVGSVGElement>) => (
56
+ <svg ref={ref} {...props}>...</svg>
57
+ ),
58
+ );
59
+
60
+ return (
61
+ <Button variant="ghost" onClick={handleClick}>
62
+ <Flex>
63
+ <span>Occurrence Limit</span>
64
+ <Popover isOpen={isOpen} trigger={<Icon />}>
65
+ This is the maximum amount of money you&apos;ll get to cover a single
66
+ incident that occurs during the policy period, regardless of when
67
+ it&apos;s reported.
68
+ </Popover>
69
+ </Flex>
70
+ </Button>
71
+ );
72
+ ```
73
+
74
+ <Canvas of={PopoverStories.QCP} />
75
+
76
+ ## Props
77
+
78
+ <ArgTypes of={Popover} />
79
+
80
+ ## Component HTML Structure and Class names
81
+
82
+ The following HTML is rendered for Popover toggle:
83
+
84
+ ```html
85
+ <button class="mobius/PopoverToggle">If only I had more information</button>
86
+ ```
87
+
88
+ Popover content:
89
+
90
+ ```html
91
+ <div class="mobius mobius/PopoverContainer">
92
+ <div class="mobius/Popover">
93
+ <header class="mobius/PopoverHeader">
94
+ <button class="mobius/PopoverCloseButton" aria-label="Close">
95
+ <svg class="mobius/PopoverCloseIcon">...</svg>
96
+ </button>
97
+ </header>
98
+ <div class="mobius/PopoverBody">
99
+ No way! Now I know everything I need to 🚀.
100
+ </div>
101
+ </div>
102
+ </div>
103
+ ```
104
+
105
+ ---
106
+
107
+ [See on Github](https://github.com/simplybusiness/mobius/tree/master/packages/mobius/src/components/Popover) | [Give feedback](https://simplybusiness.atlassian.net/CreateIssue.jspa?issuetype=10103&pid=10609) | [Get support](https://simplybusiness.slack.com/archives/C016CC0NDNE)
@@ -0,0 +1,105 @@
1
+ import type { Meta } from "@storybook/react";
2
+ import { Ref, forwardRef, useState } from "react";
3
+ import { Button, Flex, Popover, PopoverProps } from "..";
4
+ import { excludeControls } from "../../utils/excludeControls";
5
+
6
+ export default {
7
+ title: "Components/Popover",
8
+ component: Popover,
9
+ argTypes: {
10
+ isOpen: {
11
+ control: { type: "boolean" },
12
+ },
13
+ ...excludeControls(
14
+ "className",
15
+ "children",
16
+ "trigger",
17
+ "id",
18
+ "onOpen",
19
+ "onClose",
20
+ ),
21
+ },
22
+ decorators: [
23
+ Story => {
24
+ // eslint-disable-next-line global-require
25
+ require("./Popover.story.styles.css");
26
+
27
+ return <Story />;
28
+ },
29
+ ],
30
+ } satisfies Meta<typeof Popover>;
31
+
32
+ type QuestionIconProps = {};
33
+
34
+ const QuestionIcon = forwardRef(
35
+ (props: QuestionIconProps, ref: Ref<SVGSVGElement>) => (
36
+ <svg
37
+ xmlns="http://www.w3.org/2000/svg"
38
+ viewBox="0 0 16 16"
39
+ fill="none"
40
+ stroke="currentColor"
41
+ width="16px"
42
+ height="16px"
43
+ ref={ref}
44
+ {...props}
45
+ >
46
+ <g clipPath="url(#clip0_1641_88567)">
47
+ <path
48
+ d="M6.25 6.25002C6.25017 5.36011 6.91818 4.61203 7.80241 4.51155C8.68663 4.41107 9.50546 4.99019 9.7053 5.85737C9.90515 6.72456 9.42235 7.60364 8.58333 7.90027C8.23357 8.02393 7.99981 8.35471 8 8.72569V9.31253"
49
+ stroke="currentColor"
50
+ strokeLinecap="round"
51
+ strokeLinejoin="round"
52
+ />
53
+ <path
54
+ d="M8 11.0625C7.87919 11.0625 7.78125 11.1604 7.78125 11.2812C7.78125 11.4021 7.87919 11.5 8 11.5C8.12081 11.5 8.21875 11.4021 8.21875 11.2812C8.21875 11.1604 8.12081 11.0625 8 11.0625V11.0625"
55
+ stroke="currentColor"
56
+ strokeLinecap="round"
57
+ strokeLinejoin="round"
58
+ />
59
+ <circle cx="8" cy="8" r="6.5625" stroke="currentColor" />
60
+ </g>
61
+ <defs>
62
+ <clipPath id="clip0_1641_88567">
63
+ <rect width="16" height="16" fill="white" />
64
+ </clipPath>
65
+ </defs>
66
+ </svg>
67
+ ),
68
+ );
69
+
70
+ export const Normal: Meta<typeof Popover> = {
71
+ render: (args: PopoverProps) => <Popover {...args} />,
72
+ args: {
73
+ isOpen: undefined,
74
+ children: <>No way! Now I know everything I need to 🚀.</>,
75
+ trigger: <Button variant="primary">If only I had more information</Button>,
76
+ },
77
+ };
78
+
79
+ export const QCP: Meta<typeof Popover> = {
80
+ render: (args: PopoverProps) => {
81
+ // eslint-disable-next-line react-hooks/rules-of-hooks
82
+ const [isOpen, setIsOpen] = useState<boolean>(false);
83
+ const handleClick = () => setIsOpen(!isOpen);
84
+ return (
85
+ <div className="popover-example">
86
+ <Button variant="ghost" onClick={handleClick}>
87
+ <Flex>
88
+ <span>Occurrence Limit</span>
89
+ <Popover {...args} isOpen={isOpen} />
90
+ </Flex>
91
+ </Button>
92
+ </div>
93
+ );
94
+ },
95
+ args: {
96
+ trigger: <QuestionIcon />,
97
+ children: (
98
+ <>
99
+ This is the maximum amount of money you&apos;ll get to cover a single
100
+ incident that occurs during the policy period, regardless of when
101
+ it&apos;s reported.
102
+ </>
103
+ ),
104
+ },
105
+ };
@@ -0,0 +1,25 @@
1
+ .popover-example {
2
+ max-width: 500px;
3
+ margin: 0 auto;
4
+ }
5
+
6
+ .popover-example .mobius\/Button {
7
+ border: none;
8
+ padding: 0;
9
+ font-weight: var(--font-weight-normal);
10
+ font-size: var(--font-size-2);
11
+ color: var(--color-neutral-700);
12
+ background-color: transparent;
13
+ }
14
+
15
+ .popover-example .mobius\/Button span {
16
+ text-decoration: underline;
17
+ text-decoration-style: dotted;
18
+ text-underline-offset: 6px;
19
+ margin-bottom: 6px;
20
+ }
21
+
22
+ .popover-example .mobius\/PopoverToggle {
23
+ width: 16px;
24
+ margin-left: 4px;
25
+ }
@@ -0,0 +1,186 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { Popover } from ".";
3
+
4
+ const CONTAINER_ID = "react-tiny-popover-container";
5
+ const CONTAINER_CLASS_NAME = "mobius/PopoverContainer";
6
+ const TOGGLE_CLASS_NAME = "mobius/PopoverToggle";
7
+ const POPOVER_CLASS_NAME = "mobius/Popover";
8
+ const HEADER_CLASS_NAME = "mobius/PopoverHeader";
9
+ const CLOSE_BUTTON_CLASS_NAME = "mobius/PopoverCloseButton";
10
+ const CLOSE_ICON_CLASS_NAME = "mobius/PopoverCloseIcon";
11
+ const BODY_CLASS_NAME = "mobius/PopoverBody";
12
+
13
+ describe("Popover", () => {
14
+ it("should render without errors", () => {
15
+ const component = render(
16
+ <Popover trigger={<button type="button">Click me</button>}>
17
+ Sample Text
18
+ </Popover>,
19
+ );
20
+
21
+ expect(component).toBeTruthy();
22
+ });
23
+
24
+ it("should open when clicked", async () => {
25
+ const sampleText = "Sample Text";
26
+ const triggerText = "Click me";
27
+
28
+ render(
29
+ <Popover trigger={<button type="button">{triggerText}</button>}>
30
+ {sampleText}
31
+ </Popover>,
32
+ );
33
+
34
+ const button = screen.getByText(triggerText);
35
+
36
+ fireEvent.click(button);
37
+
38
+ await waitFor(() => {
39
+ expect(screen.queryByText(sampleText)).toBeInTheDocument();
40
+ });
41
+
42
+ const closeButton = screen.getByLabelText("Close");
43
+
44
+ fireEvent.click(closeButton);
45
+
46
+ expect(screen.queryByText(sampleText)).not.toBeInTheDocument();
47
+ });
48
+
49
+ describe("uses correct class names", () => {
50
+ it("uses correct classes", async () => {
51
+ const sampleText = "Sample Text";
52
+ const triggerText = "Click me";
53
+
54
+ render(
55
+ <Popover trigger={<button type="button">{triggerText}</button>}>
56
+ {sampleText}
57
+ </Popover>,
58
+ );
59
+
60
+ const button = screen.getByText(triggerText);
61
+
62
+ expect(button).toHaveClass(TOGGLE_CLASS_NAME);
63
+
64
+ fireEvent.click(button);
65
+
66
+ const popoverContainer = document.querySelector(`#${CONTAINER_ID}`)
67
+ ?.firstChild;
68
+
69
+ await waitFor(() => {
70
+ expect(popoverContainer).toHaveClass("mobius");
71
+ });
72
+
73
+ const closeButton = screen.getByLabelText("Close");
74
+ const body = screen.getByText(sampleText);
75
+
76
+ expect(popoverContainer).toHaveClass(CONTAINER_CLASS_NAME);
77
+ expect(popoverContainer?.firstChild).toHaveClass(POPOVER_CLASS_NAME);
78
+ expect(closeButton.parentElement).toHaveClass(HEADER_CLASS_NAME);
79
+ expect(closeButton).toHaveClass(CLOSE_BUTTON_CLASS_NAME);
80
+ expect(closeButton.firstChild).toHaveClass(CLOSE_ICON_CLASS_NAME);
81
+ expect(body).toHaveClass(BODY_CLASS_NAME);
82
+ });
83
+
84
+ it("includes custom class name", async () => {
85
+ const customClassName = "my-class";
86
+ const sampleText = "Sample Text";
87
+ const triggerText = "Click me";
88
+
89
+ render(
90
+ <Popover
91
+ className={customClassName}
92
+ trigger={<button type="button">{triggerText}</button>}
93
+ >
94
+ {sampleText}
95
+ </Popover>,
96
+ );
97
+
98
+ const button = screen.getByText(triggerText);
99
+
100
+ fireEvent.click(button);
101
+
102
+ const popoverContainer = document.querySelector(`#${CONTAINER_ID}`);
103
+
104
+ await waitFor(() => {
105
+ expect(popoverContainer?.firstChild).toHaveClass(customClassName);
106
+ });
107
+ });
108
+ });
109
+
110
+ describe("events", () => {
111
+ describe("given isOpen prop is set", () => {
112
+ it("should not call onOpen or onClose when first rendered", () => {
113
+ const sampleText = "Sample Text";
114
+ const triggerText = "Click me";
115
+ const onOpen = jest.fn();
116
+ const onClose = jest.fn();
117
+
118
+ render(
119
+ <Popover
120
+ isOpen={false}
121
+ trigger={<button type="button">{triggerText}</button>}
122
+ onOpen={onOpen}
123
+ onClose={onClose}
124
+ >
125
+ {sampleText}
126
+ </Popover>,
127
+ );
128
+
129
+ expect(onOpen).not.toHaveBeenCalled();
130
+ expect(onClose).not.toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ it("calls onOpen when Popover is opened", () => {
135
+ const sampleText = "Sample Text";
136
+ const triggerText = "Click me";
137
+ const onOpen = jest.fn();
138
+ const onClose = jest.fn();
139
+
140
+ render(
141
+ <Popover
142
+ trigger={<button type="button">{triggerText}</button>}
143
+ onOpen={onOpen}
144
+ onClose={onClose}
145
+ >
146
+ {sampleText}
147
+ </Popover>,
148
+ );
149
+
150
+ const button = screen.getByText(triggerText);
151
+
152
+ fireEvent.click(button);
153
+
154
+ expect(onOpen).toHaveBeenCalled();
155
+ expect(onClose).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it("calls onClose when Popover is closed", () => {
159
+ const sampleText = "Sample Text";
160
+ const triggerText = "Click me";
161
+ const onOpen = jest.fn();
162
+ const onClose = jest.fn();
163
+
164
+ render(
165
+ <Popover
166
+ trigger={<button type="button">{triggerText}</button>}
167
+ onOpen={onOpen}
168
+ onClose={onClose}
169
+ >
170
+ {sampleText}
171
+ </Popover>,
172
+ );
173
+
174
+ const button = screen.getByText(triggerText);
175
+
176
+ fireEvent.click(button);
177
+
178
+ const closeButton = screen.getByLabelText("Close");
179
+
180
+ fireEvent.click(closeButton);
181
+
182
+ expect(onClose).toHaveBeenCalled();
183
+ expect(onOpen).toHaveBeenCalledTimes(1);
184
+ });
185
+ });
186
+ });
@@ -0,0 +1,137 @@
1
+ import { cross } from "@simplybusiness/icons";
2
+ import {
3
+ ReactElement,
4
+ ReactNode,
5
+ Ref,
6
+ RefAttributes,
7
+ cloneElement,
8
+ useCallback,
9
+ useEffect,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import classNames from "classnames";
14
+ import { Popover as TinyPopover } from "react-tiny-popover";
15
+ import { useWindowEvent } from "../../hooks";
16
+ import { DOMProps } from "../../types/dom";
17
+ import { Button } from "../Button";
18
+ import { Icon } from "../Icon";
19
+
20
+ export type PopoverElementType = HTMLDivElement;
21
+
22
+ export interface PopoverProps
23
+ extends DOMProps,
24
+ RefAttributes<PopoverElementType> {
25
+ children?: ReactNode;
26
+ trigger: ReactElement;
27
+ isOpen?: boolean;
28
+ /** Callback that fires each time the accordion is opened */
29
+ onOpen?: () => void;
30
+ /** Callback that fires each time the accordion is closed */
31
+ onClose?: () => void;
32
+ /** Custom class name for setting specific CSS */
33
+ className?: string;
34
+ }
35
+
36
+ export type PopoverRef = Ref<PopoverElementType>;
37
+
38
+ export const Popover = (props: PopoverProps) => {
39
+ const popoverRef = useRef(null);
40
+ const triggerRef = useRef(null);
41
+ const { trigger, children, isOpen, onOpen, onClose, className } = props;
42
+ const previousIsOpen = useRef<boolean | undefined>(isOpen);
43
+ const hasExternalState = typeof isOpen !== "undefined";
44
+ const [open, setOpen] = useState(isOpen);
45
+ const containerClasses = classNames(
46
+ "mobius",
47
+ "mobius/PopoverContainer",
48
+ className,
49
+ );
50
+ const noop = () => {};
51
+
52
+ const openPopover = useCallback(() => {
53
+ setOpen(true);
54
+
55
+ if (onOpen) {
56
+ onOpen();
57
+ }
58
+ }, [onOpen]);
59
+
60
+ const closePopover = useCallback(() => {
61
+ setOpen(false);
62
+
63
+ if (onClose) {
64
+ onClose();
65
+ }
66
+ }, [onClose]);
67
+
68
+ const handleClick = () => {
69
+ if (open) {
70
+ closePopover();
71
+ return;
72
+ }
73
+
74
+ openPopover();
75
+ };
76
+
77
+ const triggerComponent = cloneElement(trigger, {
78
+ className: "mobius/PopoverToggle",
79
+ onClick: hasExternalState ? noop : handleClick,
80
+ });
81
+
82
+ useWindowEvent("keydown", e => {
83
+ if (open && e.key === "Escape") {
84
+ closePopover();
85
+ }
86
+ });
87
+
88
+ useEffect(() => {
89
+ if (isOpen) {
90
+ openPopover();
91
+ return;
92
+ }
93
+
94
+ // Prevent 'onClose' being called when
95
+ // 'isOpen === false' on initial render
96
+ if (previousIsOpen.current === isOpen) {
97
+ previousIsOpen.current = undefined;
98
+ return;
99
+ }
100
+
101
+ if (!isOpen) {
102
+ closePopover();
103
+ }
104
+ }, [isOpen, closePopover, openPopover]);
105
+
106
+ return (
107
+ <TinyPopover
108
+ isOpen={!!open}
109
+ positions={["bottom"]}
110
+ ref={triggerRef}
111
+ content={
112
+ <div className={containerClasses} ref={popoverRef}>
113
+ <div className="mobius/Popover">
114
+ <header className="mobius/PopoverHeader">
115
+ <Button
116
+ type="button"
117
+ className="mobius/PopoverCloseButton"
118
+ onClick={handleClick}
119
+ aria-label="Close"
120
+ variant="ghost"
121
+ >
122
+ <Icon
123
+ icon={cross}
124
+ size="md"
125
+ className="mobius/PopoverCloseIcon"
126
+ />
127
+ </Button>
128
+ </header>
129
+ <div className="mobius/PopoverBody">{children}</div>
130
+ </div>
131
+ </div>
132
+ }
133
+ >
134
+ {triggerComponent}
135
+ </TinyPopover>
136
+ );
137
+ };
@@ -0,0 +1 @@
1
+ export * from "./Popover";
@@ -23,12 +23,13 @@ export * from "./Modal";
23
23
  export * from "./NumberField";
24
24
  export * from "./Option";
25
25
  export * from "./PasswordField";
26
+ export * from "./Popover";
26
27
  export * from "./Progress";
27
28
  export * from "./Radio";
28
- export * from "./SVG";
29
29
  export * from "./Segment";
30
30
  export * from "./Select";
31
31
  export * from "./Slider";
32
+ export * from "./SVG";
32
33
  export * from "./Table";
33
34
  export * from "./Text";
34
35
  export * from "./TextArea";