From c8be5463eea2fe17fea8e78cd46aafa2622f3f35 Mon Sep 17 00:00:00 2001 From: Mike Perrotti Date: Wed, 4 Sep 2019 21:23:51 -0400 Subject: [PATCH] feat: adds CodeSnippet and ClickToCopyButton components --- package-lock.json | 10 +- package.json | 1 + packages/clicktocopybutton/README.md | 3 + .../components/ClickToCopy.tsx | 42 ++ .../components/ClickToCopyButton.tsx | 39 ++ packages/clicktocopybutton/index.ts | 1 + .../stories/ClickToCopyButton.stories.tsx | 37 ++ .../helpers/ClickToCopyTooltipHelper.tsx | 45 ++ .../tests/ClickToCopyButton.test.tsx | 40 ++ .../ClickToCopyButton.test.tsx.snap | 113 +++++ packages/codesnippet/README.md | 3 + .../codesnippet/components/CodeSnippet.tsx | 48 +++ packages/codesnippet/index.ts | 1 + .../stories/CodeSnippet.stories.tsx | 35 ++ .../codesnippet/tests/CodeSnippet.test.tsx | 40 ++ .../__snapshots__/CodeSnippet.test.tsx.snap | 407 ++++++++++++++++++ packages/tooltip/components/Tooltip.tsx | 10 + packages/tooltip/tests/Tooltip.test.tsx | 4 +- 18 files changed, 871 insertions(+), 8 deletions(-) create mode 100644 packages/clicktocopybutton/README.md create mode 100644 packages/clicktocopybutton/components/ClickToCopy.tsx create mode 100644 packages/clicktocopybutton/components/ClickToCopyButton.tsx create mode 100644 packages/clicktocopybutton/index.ts create mode 100644 packages/clicktocopybutton/stories/ClickToCopyButton.stories.tsx create mode 100644 packages/clicktocopybutton/stories/helpers/ClickToCopyTooltipHelper.tsx create mode 100644 packages/clicktocopybutton/tests/ClickToCopyButton.test.tsx create mode 100644 packages/clicktocopybutton/tests/__snapshots__/ClickToCopyButton.test.tsx.snap create mode 100644 packages/codesnippet/README.md create mode 100644 packages/codesnippet/components/CodeSnippet.tsx create mode 100644 packages/codesnippet/index.ts create mode 100644 packages/codesnippet/stories/CodeSnippet.stories.tsx create mode 100644 packages/codesnippet/tests/CodeSnippet.test.tsx create mode 100644 packages/codesnippet/tests/__snapshots__/CodeSnippet.test.tsx.snap diff --git a/package-lock.json b/package-lock.json index 5c00ba9a4..568f47845 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10252,10 +10252,9 @@ "dev": true }, "copy-to-clipboard": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.1.0.tgz", - "integrity": "sha512-+RNyDq266tv5aGhfRsL6lxgj8Y6sCvTrVJnFUVvuxuqkcSMaLISt1wd4JkdQSphbcLTIQ9kEpTULNnoCXAFdng==", - "dev": true, + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.2.0.tgz", + "integrity": "sha512-eOZERzvCmxS8HWzugj4Uxl8OJxa7T2k1Gi0X5qavwydHIfuSHq2dTD09LOg/XyGq4Zpb5IsR/2OJ5lbOegz78w==", "requires": { "toggle-selection": "^1.0.6" } @@ -24707,8 +24706,7 @@ "toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", - "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI=", - "dev": true + "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI=" }, "touch": { "version": "2.0.2", diff --git a/package.json b/package.json index 3910d39dc..25ac662c1 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/chartist": "^0.9.46", "@types/react-tabs": "^2.3.1", "chartist": "^0.11.0", + "copy-to-clipboard": "3.2.0", "downshift": "3.2.10", "emotion": "9.2.12", "emotion-theming": "9.2.9", diff --git a/packages/clicktocopybutton/README.md b/packages/clicktocopybutton/README.md new file mode 100644 index 000000000..158424c2e --- /dev/null +++ b/packages/clicktocopybutton/README.md @@ -0,0 +1,3 @@ +# ClickToCopyButton + +The `ClickToCopyButton` is used to give users a shortcut to copy text to their clipboard. It is most commonly used to copy code snippets, or configuration content. diff --git a/packages/clicktocopybutton/components/ClickToCopy.tsx b/packages/clicktocopybutton/components/ClickToCopy.tsx new file mode 100644 index 000000000..4276be467 --- /dev/null +++ b/packages/clicktocopybutton/components/ClickToCopy.tsx @@ -0,0 +1,42 @@ +import copy from "copy-to-clipboard"; + +interface ChildProps { + onClick: () => void; +} + +export interface ClickToCopyBaseProps { + /** + * This is what will end up on the user's clipboard + */ + textToCopy: string; + + /** + * Function to execute after text has been copied + */ + onCopy?: () => void; +} + +interface ClickToCopyProps extends ClickToCopyBaseProps { + /** + * Render prop to display content and trigger copy using onClick prop + */ + children: (props: ChildProps) => JSX.Element; +} + +/** + * Consumers of this component should provide a child render prop function + * that takes a function parameter and returns a component that executes that function using + * a trigger element such as a button with an onClick handler. + */ +const ClickToCopy = ({ textToCopy, onCopy, children }: ClickToCopyProps) => { + const onClick = () => { + copy(textToCopy); + if (onCopy && typeof onCopy === "function") { + onCopy(); + } + }; + + return children({ onClick }); +}; + +export default ClickToCopy; diff --git a/packages/clicktocopybutton/components/ClickToCopyButton.tsx b/packages/clicktocopybutton/components/ClickToCopyButton.tsx new file mode 100644 index 000000000..411b7fc5d --- /dev/null +++ b/packages/clicktocopybutton/components/ClickToCopyButton.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; +import ClickToCopy, { ClickToCopyBaseProps } from "./ClickToCopy"; +import { Box } from "../../styleUtils/modifiers"; +import Clickable from "../../clickable/components/clickable"; +import Icon from "../../icon/components/Icon"; +import { SystemIcons } from "../../icons/dist/system-icons-enum"; +import { tintContent } from "../../shared/styles/styleUtils"; + +interface ClickToCopyButtonProps extends ClickToCopyBaseProps { + children?: React.ReactNode; + /** + * Color of the clipboard icon or button content + */ + color?: React.CSSProperties["color"]; +} + +const ClickToCopyButton = ({ + children, + color, + ...other +}: ClickToCopyButtonProps) => { + return ( + + {({ onClick }) => ( + + {children ? ( +
{children}
+ ) : ( + + + + )} +
+ )} +
+ ); +}; + +export default ClickToCopyButton; diff --git a/packages/clicktocopybutton/index.ts b/packages/clicktocopybutton/index.ts new file mode 100644 index 000000000..d0c0d5620 --- /dev/null +++ b/packages/clicktocopybutton/index.ts @@ -0,0 +1 @@ +export { default as ClickToCopyButton } from "./components/ClickToCopyButton"; diff --git a/packages/clicktocopybutton/stories/ClickToCopyButton.stories.tsx b/packages/clicktocopybutton/stories/ClickToCopyButton.stories.tsx new file mode 100644 index 000000000..b92d6667e --- /dev/null +++ b/packages/clicktocopybutton/stories/ClickToCopyButton.stories.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { storiesOf } from "@storybook/react"; +import { withReadme } from "storybook-readme"; +import { ClickToCopyButton } from "../index"; +import Tooltip from "../../tooltip/components/Tooltip"; +import ClickToCopyTooltipHelper from "./helpers/ClickToCopyTooltipHelper"; +import { Box } from "../../styleUtils/modifiers"; + +const readme = require("../README.md"); +const textToCopy = "Nobody likes a copycat"; + +storiesOf("ClickToCopyButton", module) + .addDecorator(withReadme([readme])) + .add("default", () => ) + .add("show tooltip onCopy", () => ( + + {({ onCopy, tooltipIsVisible }) => ( + + + } + open={tooltipIsVisible} + suppress={true} + > + "{textToCopy}" copied + + + )} + + )) + .add("custom children", () => ( + +
{`Click here to copy the text: "${textToCopy}"`}
+
+ )); diff --git a/packages/clicktocopybutton/stories/helpers/ClickToCopyTooltipHelper.tsx b/packages/clicktocopybutton/stories/helpers/ClickToCopyTooltipHelper.tsx new file mode 100644 index 000000000..174e5d3ca --- /dev/null +++ b/packages/clicktocopybutton/stories/helpers/ClickToCopyTooltipHelper.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; + +interface RenderProps { + tooltipIsVisible: boolean; + onCopy: () => void; +} + +interface CTCButtonTooltipHelperProps { + children: (renderProps: RenderProps) => React.ReactNode; +} + +interface CTCButtonTooltipHelperState { + tooltipIsVisible: boolean; +} + +class ClickToCopyButtonTooltipHelper extends React.PureComponent< + CTCButtonTooltipHelperProps, + CTCButtonTooltipHelperState +> { + constructor(props) { + super(props); + + this.state = { + tooltipIsVisible: false + }; + + this.handleOnCopy = this.handleOnCopy.bind(this); + } + + public render() { + return this.props.children({ + onCopy: this.handleOnCopy, + tooltipIsVisible: this.state.tooltipIsVisible + }); + } + + private handleOnCopy() { + this.setState({ tooltipIsVisible: true }); + setTimeout(() => { + this.setState({ tooltipIsVisible: false }); + }, 2000); + } +} + +export default ClickToCopyButtonTooltipHelper; diff --git a/packages/clicktocopybutton/tests/ClickToCopyButton.test.tsx b/packages/clicktocopybutton/tests/ClickToCopyButton.test.tsx new file mode 100644 index 000000000..a7e444302 --- /dev/null +++ b/packages/clicktocopybutton/tests/ClickToCopyButton.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import * as emotion from "emotion"; +import { createSerializer } from "jest-emotion"; +import { mount } from "enzyme"; +import toJson from "enzyme-to-json"; + +import { ClickToCopyButton } from "../"; + +expect.addSnapshotSerializer(createSerializer(emotion)); + +const textToCopy = "text to copy"; + +jest.mock("copy-to-clipboard", () => jest.fn()); + +describe("ClickToCopyButton", () => { + it("renders default", () => { + const component = mount(); + + expect(toJson(component)).toMatchSnapshot(); + }); + it("renders with custom children", () => { + const component = mount( + + custom children + + ); + + expect(toJson(component)).toMatchSnapshot(); + }); + it("calls onCopy when clicked ", () => { + const onCopyFn = jest.fn(); + const component = mount( + + ); + + expect(onCopyFn).not.toHaveBeenCalled(); + component.simulate("click"); + expect(onCopyFn).toHaveBeenCalled(); + }); +}); diff --git a/packages/clicktocopybutton/tests/__snapshots__/ClickToCopyButton.test.tsx.snap b/packages/clicktocopybutton/tests/__snapshots__/ClickToCopyButton.test.tsx.snap new file mode 100644 index 000000000..149407248 --- /dev/null +++ b/packages/clicktocopybutton/tests/__snapshots__/ClickToCopyButton.test.tsx.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClickToCopyButton renders default 1`] = ` +.emotion-2 { + cursor: pointer; +} + +.emotion-1 { + background-repeat: no-repeat; + display: inline-block; + cursor: pointer; +} + +.emotion-0 { + vertical-align: middle; + fill: currentColor; +} + +.emotion-0 use { + pointer-events: none; +} + + + + + + + + + + + + + + + + +`; + +exports[`ClickToCopyButton renders with custom children 1`] = ` +.emotion-0 { + cursor: pointer; +} + + + + +
+ custom children +
+
+
+
+`; diff --git a/packages/codesnippet/README.md b/packages/codesnippet/README.md new file mode 100644 index 000000000..9b35d9c1c --- /dev/null +++ b/packages/codesnippet/README.md @@ -0,0 +1,3 @@ +# CodeSnippet + +The `CodeSnippet` component is used to visually separate code from other content in the UI, and sets the code in a monospace font to make it easier to read. diff --git a/packages/codesnippet/components/CodeSnippet.tsx b/packages/codesnippet/components/CodeSnippet.tsx new file mode 100644 index 000000000..40f84f767 --- /dev/null +++ b/packages/codesnippet/components/CodeSnippet.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { MonospaceText } from "../../styleUtils/typography"; +import { SpacingBox } from "../../styleUtils/modifiers"; +import { Flex, FlexItem } from "../../styleUtils/layout"; +import { + greyDark, + themeTextColorPrimaryInverted +} from "../../design-tokens/build/js/designTokens"; +import { ClickToCopyButton } from "../../clicktocopybutton"; + +export interface CodeSnippetProps { + children?: string; + + /** + * This is what will end up on the user's clipboard + */ + textToCopy?: string; + + /** + * Function to execute after text has been copied + */ + onCopy?: () => void; +} + +const CodeSnippet = ({ children, textToCopy, onCopy }: CodeSnippetProps) => { + return ( + + + + + {children} + + + {textToCopy ? ( + + + + ) : null} + + + ); +}; + +export default CodeSnippet; diff --git a/packages/codesnippet/index.ts b/packages/codesnippet/index.ts new file mode 100644 index 000000000..83f5840a9 --- /dev/null +++ b/packages/codesnippet/index.ts @@ -0,0 +1 @@ +export { default as CodeSnippet } from "./components/CodeSnippet"; diff --git a/packages/codesnippet/stories/CodeSnippet.stories.tsx b/packages/codesnippet/stories/CodeSnippet.stories.tsx new file mode 100644 index 000000000..3e55113c7 --- /dev/null +++ b/packages/codesnippet/stories/CodeSnippet.stories.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { storiesOf } from "@storybook/react"; +import { withReadme } from "storybook-readme"; +import { CodeSnippet } from "../index"; +import ClickToCopyTooltipHelper from "../../clicktocopybutton/stories/helpers/ClickToCopyTooltipHelper"; +import Tooltip from "../../tooltip/components/Tooltip"; + +const readme = require("../README.md"); + +const snippetContent = `cd ui-kit && npm start`; + +storiesOf("CodeSnippet", module) + .addDecorator(withReadme([readme])) + .add("default", () => {snippetContent}) + .add("with textToCopy", () => ( + {snippetContent} + )) + .add("with textToCopy + show tooltip onCopy", () => ( + + {({ onCopy, tooltipIsVisible }) => ( + + {snippetContent} + + } + open={tooltipIsVisible} + suppress={true} + > + "{snippetContent}" copied + + )} + + )); diff --git a/packages/codesnippet/tests/CodeSnippet.test.tsx b/packages/codesnippet/tests/CodeSnippet.test.tsx new file mode 100644 index 000000000..ceff6635f --- /dev/null +++ b/packages/codesnippet/tests/CodeSnippet.test.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import * as emotion from "emotion"; +import { createSerializer } from "jest-emotion"; +import { mount } from "enzyme"; +import toJson from "enzyme-to-json"; + +import { CodeSnippet } from "../"; + +expect.addSnapshotSerializer(createSerializer(emotion)); + +const snippetContent = "snippet content"; + +jest.mock("copy-to-clipboard", () => jest.fn()); + +describe("CodeSnippet", () => { + it("renders default", () => { + const component = mount({snippetContent}); + + expect(toJson(component)).toMatchSnapshot(); + }); + it("renders with copy button", () => { + const component = mount( + {snippetContent} + ); + + expect(toJson(component)).toMatchSnapshot(); + }); + it("calls onCopy when copy button is clicked ", () => { + const onCopyFn = jest.fn(); + const component = mount( + + {snippetContent} + + ); + + expect(onCopyFn).not.toHaveBeenCalled(); + component.find('[role="button"] svg').simulate("click"); + expect(onCopyFn).toHaveBeenCalled(); + }); +}); diff --git a/packages/codesnippet/tests/__snapshots__/CodeSnippet.test.tsx.snap b/packages/codesnippet/tests/__snapshots__/CodeSnippet.test.tsx.snap new file mode 100644 index 000000000..8b3d18829 --- /dev/null +++ b/packages/codesnippet/tests/__snapshots__/CodeSnippet.test.tsx.snap @@ -0,0 +1,407 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CodeSnippet renders default 1`] = ` +.emotion-5 { + padding: 16px; +} + +.emotion-4 { + background-color: #1B2029; + background-repeat: no-repeat; + padding: 16px; +} + +.emotion-3 { + -webkit-align-items: flex-start; + -webkit-box-align: flex-start; + -ms-flex-align: flex-start; + align-items: flex-start; + height: auto; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + box-sizing: border-box; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + min-height: 0; +} + +.emotion-3 > div { + width: auto; +} + +.emotion-3 > *:not(:first-child) { + padding-left: 0; + padding-top: 0; +} + +.emotion-2 { + box-sizing: border-box; + -webkit-flex-basis: 0; + -ms-flex-preferred-size: 0; + flex-basis: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + min-width: 0; + width: auto; +} + +.emotion-1 { + font-family: monospace; +} + +.emotion-0 { + font-family: monospace; + line-height: 1.4; + font-weight: 500; + font-size: 14px; + color: var(--themeTextColorPrimaryInverted,#FFFFFF); + fill: var(--themeTextColorPrimaryInverted,#FFFFFF); + margin: 0; + text-align: inherit; +} + + + + +
+ +
+ +
+ + + + snippet content + + + +
+
+
+
+
+
+
+
+`; + +exports[`CodeSnippet renders with copy button 1`] = ` +.emotion-5 { + cursor: pointer; +} + +.emotion-9 { + padding: 16px; +} + +.emotion-8 { + background-color: #1B2029; + background-repeat: no-repeat; + padding: 16px; +} + +.emotion-7 { + -webkit-align-items: flex-start; + -webkit-box-align: flex-start; + -ms-flex-align: flex-start; + align-items: flex-start; + height: auto; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + box-sizing: border-box; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + min-height: 0; +} + +.emotion-7 > div { + width: auto; +} + +.emotion-7 > *:not(:first-child) { + padding-left: 0; + padding-top: 0; +} + +.emotion-2 { + box-sizing: border-box; + -webkit-flex-basis: 0; + -ms-flex-preferred-size: 0; + flex-basis: 0; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + min-width: 0; + width: auto; +} + +.emotion-1 { + font-family: monospace; +} + +.emotion-0 { + font-family: monospace; + line-height: 1.4; + font-weight: 500; + font-size: 14px; + color: var(--themeTextColorPrimaryInverted,#FFFFFF); + fill: var(--themeTextColorPrimaryInverted,#FFFFFF); + margin: 0; + text-align: inherit; +} + +.emotion-6 { + box-sizing: border-box; + -webkit-flex-basis: auto; + -ms-flex-preferred-size: auto; + flex-basis: auto; + -webkit-box-flex: 0; + -webkit-flex-grow: 0; + -ms-flex-positive: 0; + flex-grow: 0; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + width: initial; +} + +.emotion-4 { + background-repeat: no-repeat; + display: inline-block; + cursor: pointer; +} + +.emotion-3 { + vertical-align: middle; + fill: var(--themeTextColorPrimaryInverted,#FFFFFF); +} + +.emotion-3 use { + pointer-events: none; +} + + + + +
+ +
+ +
+ + + + snippet content + + + +
+
+ +
+ + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+`; diff --git a/packages/tooltip/components/Tooltip.tsx b/packages/tooltip/components/Tooltip.tsx index 8778766d6..547b0c22f 100644 --- a/packages/tooltip/components/Tooltip.tsx +++ b/packages/tooltip/components/Tooltip.tsx @@ -24,6 +24,16 @@ export interface TooltipState { } class Tooltip extends React.PureComponent { + static getDerivedStateFromProps(props: TooltipProps, state: TooltipState) { + if (props.suppress && props.open !== state.open) { + return { + open: props.open + }; + } + + return null; + } + constructor(props) { super(props); diff --git a/packages/tooltip/tests/Tooltip.test.tsx b/packages/tooltip/tests/Tooltip.test.tsx index 3340f1a11..3125157ad 100644 --- a/packages/tooltip/tests/Tooltip.test.tsx +++ b/packages/tooltip/tests/Tooltip.test.tsx @@ -69,9 +69,9 @@ describe("Tooltip", () => { content ); - expect(component.state("open")).toBe(false); + expect(component.state("open")).toBeFalsy(); component.simulate("mouseEnter"); - expect(component.state("open")).toBe(false); + expect(component.state("open")).toBeFalsy(); }); it("calls onClose prop when closed", () => {