diff --git a/package.json b/package.json index 427149817..57a6fcdf4 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "roots": [ "/src" ], - "testRegex": "/__tests__/.*\\.(ts|tsx|js|jsx)$", + "testRegex": "/.*\\.test\\.(ts|tsx|js|jsx)$", "setupTestFrameworkScriptFile": "jest-enzyme", "snapshotSerializers": [ "enzyme-to-json/serializer" diff --git a/src/Card/Card.tsx b/src/Card/Card.tsx index 037a525c4..b2aa3698a 100644 --- a/src/Card/Card.tsx +++ b/src/Card/Card.tsx @@ -196,10 +196,12 @@ function Card(props: CardProps) { } if (isWithTabs(props)) { + const { onTabChange, ...otherProps } = props + return ( - + {({ tabsBar, activeChildren }) => ( - + diff --git a/src/Code/README.md b/src/Code/README.md index f07cd9a68..60f862739 100644 --- a/src/Code/README.md +++ b/src/Code/README.md @@ -74,13 +74,17 @@ document.body.innerHTML = greeter(user);`} Sometimes, you might need to quickly copy a code snippet. Here's how. ```jsx - - {({ pushMessage }) => ( +const { useOperationalContext } = require("../OperationalContext/OperationalContext") +const ComponentWithCode = () => { + const { pushMessage } = useOperationalContext() + return ( pushMessage({ type: "info", body: "Successfully Copied!" })} copyable >{`Tuuvaquae5ieroeba5eu1Dae`} - )} - + ) +} + +; ``` diff --git a/src/Input/Input.Button.tsx b/src/Input/Input.Button.tsx index 16c7187a1..168e13959 100644 --- a/src/Input/Input.Button.tsx +++ b/src/Input/Input.Button.tsx @@ -2,7 +2,7 @@ import * as React from "react" import CopyToClipboard from "react-copy-to-clipboard" import Icon, { IconName } from "../Icon/Icon" -import OperationalContext from "../OperationalContext/OperationalContext.init" +import OperationalContext from "../OperationalContext/OperationalContext" import styled from "../utils/styled" import { height } from "./Input.constants" diff --git a/src/OperationalContext/OperationalContext.init.tsx b/src/OperationalContext/OperationalContext.init.tsx deleted file mode 100644 index 8e3bdc652..000000000 --- a/src/OperationalContext/OperationalContext.init.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as React from "react" - -export interface WindowSize { - width: number - height: number -} - -export type MessageType = "info" | "success" | "error" - -export interface IMessage { - body: string - type: MessageType - onClick?: () => void -} - -export interface Context { - pushState?: (url: string) => void - replaceState?: (url: string) => void - pushMessage: (message: IMessage) => void - windowSize: WindowSize - loading: boolean - setLoading: (isLoading: boolean) => void -} - -/** - * Defining a default context value here, used below when instantiating - * the context consumer and provider below in order for context to be - * correctly detected throughout the application. - */ -const defaultContext: Context = { - pushState: undefined, - replaceState: undefined, - pushMessage: (_: IMessage) => void 0, - windowSize: { - width: 1080, - height: 640, - }, - loading: false, - setLoading: (_: boolean) => void 0, -} - -const OperationalContext = React.createContext(defaultContext) - -const { Provider, Consumer } = OperationalContext - -export const useOperationalContext = () => React.useContext(OperationalContext) - -export default Consumer - -export { Provider } diff --git a/src/OperationalContext/OperationalContext.tsx b/src/OperationalContext/OperationalContext.tsx index 20d6437a6..bfe7a172a 100644 --- a/src/OperationalContext/OperationalContext.tsx +++ b/src/OperationalContext/OperationalContext.tsx @@ -1,20 +1,38 @@ import * as React from "react" -import { Context, IMessage, MessageType, useOperationalContext, WindowSize } from "./OperationalContext.init" +export type MessageType = "info" | "success" | "error" -export interface Props { - children: (operationalContext: Context) => undefined | React.ReactNode +export interface IMessage { + body: string + type: MessageType + onClick?: () => void +} + +export interface Context { + pushState?: (url: string) => void + replaceState?: (url: string) => void + pushMessage: (message: IMessage) => void + loading: boolean + setLoading: (isLoading: boolean) => void } /** - * This component simply wraps OperationalContext in order to allow styleguidist to pick up on - * it and display it in the documentation page. + * Defining a default context value here, used below when instantiating + * the context consumer and provider below in order for context to be + * correctly detected throughout the application. */ -const OperationalContext: React.SFC = props => { - const ctx = useOperationalContext() - return <>{props.children({ ...ctx })} +const defaultContext: Context = { + pushState: undefined, + replaceState: undefined, + pushMessage: (_: IMessage) => void 0, + loading: false, + setLoading: (_: boolean) => void 0, } -export default OperationalContext +const ctx = React.createContext(defaultContext) -export { Context, WindowSize, IMessage, MessageType, useOperationalContext } +export const { Consumer: OperationalContext, Provider } = ctx + +export const useOperationalContext = () => React.useContext(ctx) + +export default OperationalContext diff --git a/src/OperationalContext/README.md b/src/OperationalContext/README.md index 69eafe533..d8457411b 100644 --- a/src/OperationalContext/README.md +++ b/src/OperationalContext/README.md @@ -1,48 +1,21 @@ The `OperationalContext` component provides utility methods and data that can be used inside `OperationalUI`: -### Window size - -Child components can get access to window dimensions as follows: - -```jsx - - {operationalContext => ( -

{`The viewport is ${operationalContext.windowSize.width} pixels wide and ${ - operationalContext.windowSize.height - } tall.`}

- )} -
-``` - -### OperationalContext Hook +### Messages and Loaders -You can get the value of `OperationalContext` in the shape of context. +You can use `OperationalUI`'s flash- and progress bar features to automatically render and manage these universal UI elements using the `pushMessage` and `setLoadingState` methods provided in context, as shown in the code snippet below: ```jsx const { useOperationalContext } = require("./OperationalContext") -const ILookPretty = () => { - const ctx = useOperationalContext() - return ( -

- The viewport is {ctx.windowSize.width} pixels wide and {ctx.windowSize.height} tall. -

- ) -} -; -``` -### Messages and Loaders +const ComponentWithContext = () => { + const { pushMessage, setLoading, loading } = useOperationalContext() -You can use `OperationalUI`'s flash- and progress bar features to automatically render and manage these universal UI elements using the `pushMessage` and `setLoadingState` methods provided in context, as shown in the code snippet below: - -```jsx - - {operationalContext => ( + return (
- )} -
+ ) +} + +; ``` diff --git a/src/OperationalUI/OperationalUI.tsx b/src/OperationalUI/OperationalUI.tsx index 86757d1cc..7f5ac1dd6 100644 --- a/src/OperationalUI/OperationalUI.tsx +++ b/src/OperationalUI/OperationalUI.tsx @@ -1,15 +1,12 @@ import { injectGlobal } from "emotion" import { ThemeProvider } from "emotion-theming" -import { Cancelable } from "lodash" -import debounce from "lodash/debounce" import merge from "lodash/merge" import * as React from "react" import ErrorBoundary from "../Internals/ErrorBoundary" import Message from "../Internals/Message/Message" import Messages from "../Internals/Messages/Messages" -import { IMessage, MessageType, WindowSize } from "../OperationalContext/OperationalContext" -import { Provider } from "../OperationalContext/OperationalContext.init" +import { IMessage, MessageType, Provider } from "../OperationalContext/OperationalContext" import Progress from "../Progress/Progress" import { darken, DeepPartial } from "../utils" import constants, { OperationalStyleConstants } from "../utils/constants" @@ -46,7 +43,6 @@ export interface OperationalUIProps { } export interface State { - windowSize: WindowSize messages: Array<{ message: IMessage addedAt: number @@ -117,10 +113,6 @@ class OperationalUI extends React.Component { } public state: State = { - windowSize: { - width: 0, - height: 0, - }, messages: [], isLoading: false, } @@ -152,25 +144,10 @@ class OperationalUI extends React.Component { } } - /** - * Explicit typing is required here in order to give the typescript compiler access to typings - * used to work out type definitions for the debounce method. - * @todo look into making this unnecessary. - */ - public handleResize: (() => void) & Cancelable = debounce(() => { - this.onSetWindowSize() - }, 200) - public setLoading = (isLoading: boolean) => { this.setState(() => ({ isLoading })) } - public onSetWindowSize = () => { - this.setState(() => ({ - windowSize: { width: window.innerWidth, height: window.innerHeight }, - })) - } - public componentDidCatch(error: Error) { this.setState({ error }) if (this.props.onError) { @@ -182,15 +159,12 @@ class OperationalUI extends React.Component { if (!this.props.noBaseStyles) { injectGlobal(baseStylesheet(constants)) } - this.onSetWindowSize() - window.addEventListener("resize", this.handleResize) } public componentWillUnmount() { if (this.messageTimerInterval) { clearInterval(this.messageTimerInterval) } - window.removeEventListener("resize", this.handleResize) } public render() { @@ -207,7 +181,6 @@ class OperationalUI extends React.Component { pushMessage: this.pushMessage, loading: this.state.isLoading, setLoading: this.setLoading, - windowSize: this.state.windowSize, }} > diff --git a/src/Tooltip/Tooltip.tsx b/src/Tooltip/Tooltip.tsx index 43794c48d..428deed7c 100644 --- a/src/Tooltip/Tooltip.tsx +++ b/src/Tooltip/Tooltip.tsx @@ -1,7 +1,7 @@ import * as React from "react" -import OperationalContext from "../OperationalContext/OperationalContext" import { DefaultProps } from "../types" +import useWindowSize from "../useWindowSize" import Container, { Position } from "./Tooltip.Container" /** @@ -75,7 +75,7 @@ export interface BottomProps extends BaseProps { export type TooltipProps = TopProps | LeftProps | RightProps | BottomProps | SmartProps -export interface State { +interface State { // bbTop is an abbreviation of boundingBoxTop bbTop: number bbBottom: number @@ -84,6 +84,47 @@ export interface State { singleLineTextWidth: number } +const getPosition = (props: TooltipProps) => { + let position = "right" as Position + + if (props.left) { + position = "left" + } + if (props.right) { + position = "right" + } + if (props.bottom) { + position = "bottom" + } + if (props.top) { + position = "top" + } + + return position +} + +const getDisplayPosition = (windowSize: { width: number; height: number }, state: State, props: TooltipProps) => { + let position = getPosition(props) + + /** Swap the positions of tooltips in case they are clipped in this particular viewport */ + if (props.smart) { + if (state.bbLeft < 0 && position === "left") { + position = "right" + } + if (state.bbTop < 0 && position === "top") { + position = "bottom" + } + if (state.bbRight > windowSize.width && position === "right") { + position = "left" + } + if (state.bbBottom > windowSize.height && position === "bottom") { + position = "top" + } + } + + return position +} + /* * This class name is used as a selector when customizing the opacity for tooltips * that are only displayed when a particular parent of theirs is hovered. @@ -93,122 +134,61 @@ export interface State { */ export const dangerousTooltipContainerClassName = "operational-ui-tooltip" -class Tooltip extends React.Component { - public state = { +const Tooltip: React.SFC = props => { + const containerNode = React.useRef(null) + const offScreenWidthTestNode = React.useRef(null) + const [state, setState] = React.useState({ bbTop: 0, bbLeft: 0, bbRight: 0, bbBottom: 0, singleLineTextWidth: 0, - } - - public containerNode?: HTMLElement - public offScreenWidthTestNode?: HTMLElement + }) - public setDomProperties() { - if (!this.offScreenWidthTestNode || !this.containerNode) { + React.useEffect(() => { + if (!offScreenWidthTestNode.current || !containerNode.current) { return } - const bbOffScreen = this.offScreenWidthTestNode.getBoundingClientRect() - const bbRect = this.containerNode.getBoundingClientRect() - this.setState({ + + const bbOffScreen = offScreenWidthTestNode.current.getBoundingClientRect() + const bbRect = containerNode.current.getBoundingClientRect() + setState({ bbTop: bbRect.top, bbBottom: bbRect.bottom, bbLeft: bbRect.left, bbRight: bbRect.right, singleLineTextWidth: bbOffScreen.width, }) - } - - public componentDidMount() { - this.setDomProperties() - } - - public getPosition() { - let position: Position = "right" - - if (this.props.left) { - position = "left" - } - - if (this.props.right) { - position = "right" - } - - if (this.props.bottom) { - position = "bottom" - } - - if (this.props.top) { - position = "top" - } - - return position - } - - public getDisplayPosition(windowSize: { width: number; height: number }) { - let position: Position = this.getPosition() - - /** Swap the positions of tooltips in case they are clipped in this particular viewport */ - if (this.props.smart) { - if (this.state.bbLeft < 0 && String(position) === "left") { - position = "right" - } - - if (this.state.bbTop < 0 && String(position) === "top") { - position = "bottom" - } - - if (this.state.bbRight > windowSize.width && String(position) === "right") { - position = "left" - } - - if (this.state.bbBottom > windowSize.height && String(position) === "bottom") { - position = "top" - } - } - - return position - } - - public render() { - return ( - - {operationalContext => { - const displayPosition = this.getDisplayPosition(operationalContext.windowSize) - return ( - <> - {/* Test node rendered to determine how wide the text is if it were written in a single line. - * Note that the position is set arbitrarily since it does not influence text width. - */} - { - this.offScreenWidthTestNode = node - }} - > - {/* Wrapping in a paragraph tag is necessary in order to have Safari read the correct single line width. */} -

{this.props.children}

-
- { - this.containerNode = node - }} - > - {/* Wrapping in a paragraph tag is necessary in order to have Safari read the correct single line width. */} -

{this.props.children}

-
- - ) - }} -
- ) - } + }, []) + + const windowSize = useWindowSize() + const displayPosition = getDisplayPosition(windowSize, state, props) + + return ( + <> + {/* Test node rendered to determine how wide the text is if it were written in a single line. + * Note that the position is set arbitrarily since it does not influence text width. + */} + + {/* Wrapping in a paragraph tag is necessary in order to have Safari read the correct single line width. */} +

{props.children}

+
+ + {/* Wrapping in a paragraph tag is necessary in order to have Safari read the correct single line width. */} +

{props.children}

+
+ + ) } export default Tooltip diff --git a/src/index.ts b/src/index.ts index 2ecefdb4b..3acd54a05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,6 +83,7 @@ export { Tab } from "./Internals/Tabs" export { default as styled } from "./utils/styled" // Hooks -export * from "./hooks/useURLState/useURLState" +export * from "./useURLState" +export * from "./useWindowSize" export default OperationalUI diff --git a/src/hooks/useURLState/README.md b/src/useURLState/README.md similarity index 97% rename from src/hooks/useURLState/README.md rename to src/useURLState/README.md index 932f127ff..6e5c13aee 100644 --- a/src/hooks/useURLState/README.md +++ b/src/useURLState/README.md @@ -2,9 +2,9 @@ It's quite convenient to be able to copy and paste a link to somebody to show th It's even more convenient if this link has the state of the current page! This is what this hook is made for. -Disclaimer: when we say synchronized, we don't mean two way binding. It will read the initial state from the URL on component mount, but afterwards, this hook's internal state will be source of truth. +Disclaimer: when we say synchronized, we don't mean two way binding. It will read the initial state from the URL on component mount, but afterwards, this hook's internal state will be source of truth. -This is ok most of the time because we use `replaceState` and there is no way from user's point of view to change the URL without triggering a page reload. +This is ok most of the time because we use `replaceState` and there is no way from user's point of view to change the URL without triggering a page reload. The only edge case to be aware of here is when you use `useURLState` with `pushState`. In this case, you will need to make sure that this hook lives inside a valid router context. diff --git a/src/hooks/useURLState/useURLState.ts b/src/useURLState/index.ts similarity index 96% rename from src/hooks/useURLState/useURLState.ts rename to src/useURLState/index.ts index fcf037de6..7347f80d0 100644 --- a/src/hooks/useURLState/useURLState.ts +++ b/src/useURLState/index.ts @@ -24,7 +24,7 @@ const getSearchParams = (search: string) => qs.parse(search.replace("?", "")) || * Create a state that is sync with url search param. * * @param name Name of your state - * @param decoder Validate and decode the value from the url + * @param decoder Validate and decode the value from the url * @param options `window` dependent methods */ export const useURLState = ( diff --git a/src/hooks/useURLState/useURLState.test.tsx b/src/useURLState/useURLState.test.tsx similarity index 99% rename from src/hooks/useURLState/useURLState.test.tsx rename to src/useURLState/useURLState.test.tsx index 1afecf5b9..54784c6c6 100644 --- a/src/hooks/useURLState/useURLState.test.tsx +++ b/src/useURLState/useURLState.test.tsx @@ -1,7 +1,7 @@ import React from "react" import { cleanup, fireEvent, render, wait } from "react-testing-library" -import { useURLState } from "./useURLState" +import { useURLState } from "." describe("useURLState", () => { afterEach(cleanup) diff --git a/src/useWindowSize/README.md b/src/useWindowSize/README.md new file mode 100644 index 000000000..d544fc6da --- /dev/null +++ b/src/useWindowSize/README.md @@ -0,0 +1,17 @@ +## Usage + +This hook is used to get the window size and updates an element that uses it, providing the current size of the viewport. + +```jsx +const MyComponent = () => { + const { width, height } = useWindowSize() + + return ( + <> + Your window is {width}px wide and {height}px high 🤘 + + ) +} + +; +``` diff --git a/src/useWindowSize/index.ts b/src/useWindowSize/index.ts new file mode 100644 index 000000000..73a2a7f93 --- /dev/null +++ b/src/useWindowSize/index.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react" + +/** + * Get the window size. + */ +export const useWindowSize = () => { + const [windowSize, setWindowSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }) + + useEffect(() => { + const handler = () => { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }) + } + window.addEventListener("resize", handler) + return () => window.removeEventListener("resize", handler) + }, []) + + return windowSize +} + +export default useWindowSize diff --git a/src/useWindowSize/useWindowSize.test.tsx b/src/useWindowSize/useWindowSize.test.tsx new file mode 100644 index 000000000..6cf916ebf --- /dev/null +++ b/src/useWindowSize/useWindowSize.test.tsx @@ -0,0 +1,60 @@ +import React from "react" +import { act, cleanup, render } from "react-testing-library" + +import { useWindowSize } from "." + +const MyComponent = () => { + const { width, height } = useWindowSize() + + return ( +
+ {width}x{height} +
+ ) +} + +describe("useURLState", () => { + afterEach(cleanup) + it("should reflect the window size", async () => { + const { getByTestId } = render() + expect(getByTestId("result")).toMatchInlineSnapshot(` +
+ 1024 + x + 768 +
+`) + }) + + it("should rerender when the window size changes", async () => { + const { getByTestId } = render() + expect(getByTestId("result")).toMatchInlineSnapshot(` +
+ 1024 + x + 768 +
+`) + act(() => { + // @ts-ignore window.innerWidth _can be_ overwritten in this case. + window.innerWidth = 200 + // @ts-ignore window.innerHeight _can be_ overwritten in this case. + window.innerHeight = 500 + window.dispatchEvent(new Event("resize")) + }) + + expect(getByTestId("result")).toMatchInlineSnapshot(` +
+ 200 + x + 500 +
+`) + }) +}) diff --git a/src/utils/color.ts b/src/utils/color.ts index 08c323e06..5b9aa348f 100644 --- a/src/utils/color.ts +++ b/src/utils/color.ts @@ -5,7 +5,7 @@ export const isWhite = (color: string) => // Maps strings deterministally to the same color. Avoids similar strings ending up with the same color. export const colorMapper = (colors: string[]) => { - return (str: string) => colors[hash32FNV1aUTF(str) % colors.length] + return (str: string) => colors[hash32FNV1aUTF(String(str)) % colors.length] } /* diff --git a/styleguide.config.js b/styleguide.config.js index 91c65b23c..9db1b1ea5 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -6,6 +6,7 @@ module.exports = { title: "Operational UI", propsParser, sections: [ + { name: "Hooks", components: "src/use*/*.ts" }, { name: "Components", components: "src/**/*.tsx", @@ -20,7 +21,6 @@ module.exports = { ], }, { name: "Typography", components: "src/Typography/*.tsx" }, - { name: "React Hooks", components: "src/hooks/**/*.ts" }, ], theme: { fontFamily: {