From 190169bd08718644377883daeeaa2dffb2c1a69d Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Fri, 12 Nov 2021 20:08:06 +0530 Subject: [PATCH 01/13] =?UTF-8?q?feat(presence):=20=E2=9C=A8=20add=20prese?= =?UTF-8?q?nce=20for=20disclosure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/disclosure/DisclosureButton.tsx | 8 +- src/disclosure/DisclosureContent.tsx | 243 ++++-------------- src/disclosure/DisclosureState.ts | 93 +------ src/disclosure/__keys.ts | 19 +- src/disclosure/helpers.tsx | 32 +-- .../stories/DisclosureBasic.component.tsx | 20 +- src/disclosure/stories/DisclosureBasic.css | 48 ++++ .../stories/DisclosureBasic.stories.tsx | 21 +- .../DisclosureHorizontal.component.tsx | 27 +- .../stories/DisclosureHorizontal.css | 38 +++ .../stories/DisclosureHorizontal.stories.tsx | 21 +- src/index.ts | 1 + src/presence/Presence.ts | 44 ++++ src/presence/PresenceState.tsx | 125 +++++++++ src/presence/__keys.ts | 7 + src/presence/helpers.tsx | 29 +++ src/presence/index.ts | 3 + .../stories/PresenceAnimated.component.tsx | 27 ++ src/presence/stories/PresenceAnimated.css | 25 ++ .../stories/PresenceAnimated.stories.tsx | 28 ++ .../stories/PresenceBasic.component.tsx | 19 ++ .../stories/PresenceBasic.stories.tsx | 22 ++ 22 files changed, 527 insertions(+), 373 deletions(-) create mode 100644 src/disclosure/stories/DisclosureBasic.css create mode 100644 src/disclosure/stories/DisclosureHorizontal.css create mode 100644 src/presence/Presence.ts create mode 100644 src/presence/PresenceState.tsx create mode 100644 src/presence/__keys.ts create mode 100644 src/presence/helpers.tsx create mode 100644 src/presence/index.ts create mode 100644 src/presence/stories/PresenceAnimated.component.tsx create mode 100644 src/presence/stories/PresenceAnimated.css create mode 100644 src/presence/stories/PresenceAnimated.stories.tsx create mode 100644 src/presence/stories/PresenceBasic.component.tsx create mode 100644 src/presence/stories/PresenceBasic.stories.tsx diff --git a/src/disclosure/DisclosureButton.tsx b/src/disclosure/DisclosureButton.tsx index dadb05b38..463b92d20 100644 --- a/src/disclosure/DisclosureButton.tsx +++ b/src/disclosure/DisclosureButton.tsx @@ -11,6 +11,7 @@ import { createComposableHook } from "../system"; import { DISCLOSURE_BUTTON_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; +import { getState } from "./helpers"; export type DisclosureButtonOptions = ButtonOptions & Pick; @@ -29,15 +30,13 @@ export const disclosureComposableButton = createComposableHook< keys: DISCLOSURE_BUTTON_KEYS, useProps(options, htmlProps) { - const { toggle, expanded } = options; + const { toggle, expanded, baseId } = options; const { onClick: htmlOnClick, "aria-controls": ariaControls, ...restHtmlProps } = htmlProps; - const controls = ariaControls - ? `${ariaControls} ${options.baseId}` - : options.baseId; + const controls = ariaControls ? `${ariaControls} ${baseId}` : baseId; const onClickRef = useLiveRef(htmlOnClick); @@ -54,6 +53,7 @@ export const disclosureComposableButton = createComposableHook< return { "aria-controls": controls, "aria-expanded": expanded, + "data-state": getState(expanded), onClick, ...restHtmlProps, }; diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index a33cb2826..5e997681e 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -1,37 +1,21 @@ // Core Logic for transition is based on https://github.com/roginfarrer/react-collapsed import * as React from "react"; -import { flushSync } from "react-dom"; import { createComponent } from "reakit-system"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { useForkRef, useLiveRef, useUpdateEffect } from "reakit-utils"; -import raf from "raf"; +import { useForkRef } from "reakit-utils"; +import { PresenceHTMLProps, PresenceOptions, usePresence } from "../presence"; import { createComposableHook } from "../system"; import { DISCLOSURE_CONTENT_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; -import { - getAutoSizeDuration, - getElementHeight, - getElementWidth, -} from "./helpers"; +import { getState } from "./helpers"; export type DisclosureContentOptions = BoxOptions & - Pick< - DisclosureStateReturn, - | "baseId" - | "expanded" - | "contentSize" - | "duration" - | "direction" - | "easing" - | "onCollapseEnd" - | "onCollapseStart" - | "onExpandEnd" - | "onExpandStart" - > & {}; + PresenceOptions & + Pick & {}; -export type DisclosureContentHTMLProps = BoxHTMLProps; +export type DisclosureContentHTMLProps = BoxHTMLProps & PresenceHTMLProps; export type DisclosureContentProps = DisclosureContentOptions & DisclosureContentHTMLProps; @@ -41,189 +25,68 @@ export const disclosureComposableContent = createComposableHook< DisclosureContentHTMLProps >({ name: "DisclosureContent", - compose: useBox, + compose: [useBox, usePresence], keys: DISCLOSURE_CONTENT_KEYS, useProps(options, htmlProps) { - const { - contentSize, - expanded, - direction, - duration, - easing, - onCollapseEnd, - onCollapseStart, - onExpandEnd, - onExpandStart, - baseId, - } = options; + const { expanded, baseId, present } = options; const { ref: htmlRef, style: htmlStyle, - onTransitionEnd: htmlOnTransitionEnd, + children: htmlChildren, ...restHtmlProps } = htmlProps; const ref = React.useRef(null); - const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); - const isVertical = direction === "vertical"; - const currentSize = isVertical ? "height" : "width"; - const getCurrentSizeStyle = React.useCallback( - (size: number) => ({ - [currentSize]: `${size}px`, - }), - [currentSize], - ); - const collapsedStyles = React.useMemo(() => { - return { - ...getCurrentSizeStyle(contentSize), - overflow: "hidden", - }; - }, [contentSize, getCurrentSizeStyle]); - - const [styles, setStylesRaw] = React.useState( - expanded ? {} : collapsedStyles, - ); - const setStyles = (newStyles: {} | ((oldStyles: {}) => {})): void => { - // We rely on reading information from layout - // at arbitrary times, so ensure all style changes - // happen before we might attempt to read them. - flushSync(() => { - setStylesRaw(newStyles); - }); - }; - const mergeStyles = React.useCallback((newStyles: {}): void => { - setStyles(oldStyles => ({ ...oldStyles, ...newStyles })); - }, []); - - function getTransitionStyles(size: number | string): { - transition?: string; - } { - const _duration = duration || getAutoSizeDuration(size); - - return { - transition: `${currentSize} ${_duration}ms ${easing}`, - }; - } - - useUpdateEffect(() => { - if (expanded) { - raf(() => { - onExpandStart?.(); - - mergeStyles({ - willChange: `${currentSize}`, - overflow: "hidden", - }); - - raf(() => { - const size = isVertical - ? getElementHeight(ref) - : getElementWidth(ref); - - mergeStyles({ - ...getTransitionStyles(size), - ...(isVertical ? { height: size } : { width: size }), - }); - }); - }); - } else { - raf(() => { - onCollapseStart?.(); - - const size = isVertical - ? getElementHeight(ref) - : getElementWidth(ref); - - mergeStyles({ - willChange: `${currentSize}`, - ...(isVertical ? { height: size } : { width: size }), - ...getTransitionStyles(size), - }); - raf(() => { - mergeStyles({ - ...getCurrentSizeStyle(contentSize), - overflow: "hidden", - }); - }); - }); + const [isPresent, setIsPresent] = React.useState(present); + const heightRef = React.useRef(0); + const height = heightRef.current; + const widthRef = React.useRef(0); + const width = widthRef.current; + // when opening we want it to immediately open to retrieve dimensions + // when closing we delay `present` to retrieve dimensions before closing + const isExpanded = expanded || isPresent; + + React.useLayoutEffect(() => { + const node = ref.current; + + if (node) { + const originalTransition = node.style.transition; + const originalAnimation = node.style.animation; + // block any animations/transitions so the element renders at its full dimensions + node.style.transition = "none"; + node.style.animation = "none"; + + // get width and height from full dimensions + const rect = node.getBoundingClientRect(); + heightRef.current = rect.height; + widthRef.current = rect.width; + + // kick off any animations/transitions that were originally set up + node.style.transition = originalTransition; + node.style.animation = originalAnimation; + setIsPresent(present); } - }, [expanded]); - - const onTransitionEnd = React.useCallback( - (event: React.TransitionEvent) => { - onTransitionEndRef.current?.(event); - - if (event.defaultPrevented) return; - - // Sometimes onTransitionEnd is triggered by another transition, - // such as a nested collapse panel transitioning. But we only - // want to handle this if this component's element is transitioning - if ( - event.target !== ref.current || - event.propertyName !== currentSize - ) { - return; - } - - // The height comparisons below are a final check before - // completing the transition - // Sometimes this callback is run even though we've already begun - // transitioning the other direction - // The conditions give us the opportunity to bail out, - // which will prevent the collapsed content from flashing on the screen - const stylesSize = isVertical ? styles.height : styles.width; - - if (expanded) { - const size = isVertical - ? getElementHeight(ref) - : getElementWidth(ref); - - // If the height at the end of the transition - // matches the height we're animating to, - if (size === stylesSize) { - setStyles({}); - } else { - // If the heights don't match, this could be due the height - // of the content changing mid-transition - mergeStyles({ - ...getCurrentSizeStyle(contentSize), - }); - } - - onExpandEnd?.(); - - // If the height we should be animating to matches the collapsed height, - // it's safe to apply the collapsed overrides - } else if (stylesSize === `${contentSize}px`) { - setStyles(collapsedStyles); - - onCollapseEnd?.(); - } - }, - [ - onTransitionEndRef, - currentSize, - isVertical, - styles.height, - styles.width, - expanded, - contentSize, - onExpandEnd, - mergeStyles, - getCurrentSizeStyle, - collapsedStyles, - onCollapseEnd, - ], - ); - - const style = { ...styles, ...htmlStyle }; + /** + * depends on `context.open` because it will change to `false` + * when a close is triggered but `present` will be `false` on + * animation end (so when close finishes). This allows us to + * retrieve the dimensions *before* closing. + */ + }, [expanded, present]); + + const style = { + "--content-height": height ? `${height}px` : undefined, + "--content-width": width ? `${width}px` : undefined, + ...htmlStyle, + }; return { ref: useForkRef(ref, htmlRef), + "data-state": getState(expanded), id: baseId, - "aria-hidden": !expanded, + hidden: !isExpanded, style, - onTransitionEnd, + children: isExpanded ? htmlChildren : null, ...restHtmlProps, }; }, diff --git a/src/disclosure/DisclosureState.ts b/src/disclosure/DisclosureState.ts index 2a8a08191..3028b0680 100644 --- a/src/disclosure/DisclosureState.ts +++ b/src/disclosure/DisclosureState.ts @@ -7,40 +7,15 @@ import { } from "reakit"; import { useControllableState } from "../utils"; +import { PresenceInitialState, PresenceState, usePresenceState } from ".."; -export type DisclosureState = unstable_IdState & { - /** - * Whether it's expanded or not. - */ - expanded: boolean; - - /** - * Direction of the transition. - * - * @default vertical - */ - direction: "vertical" | "horizontal"; - - /** - * Size of the content. - * - * @default 0 - */ - contentSize: number; - - /** - * Duration of the transition. - * By default the duration is calculated based on the size of change. - */ - duration?: number; - - /** - * Transition Easing. - * - * @default cubic-bezier(0.4, 0, 0.2, 1) - */ - easing: string; -}; +export type DisclosureState = unstable_IdState & + PresenceState & { + /** + * Whether it's expanded or not. + */ + expanded: boolean; + }; export type DisclosureActions = unstable_IdActions & { /** @@ -64,41 +39,13 @@ export type DisclosureActions = unstable_IdActions & { setExpanded: React.Dispatch< React.SetStateAction >; - - /** - * Callback called before the expand transition starts. - */ - onExpandStart?: () => void; - - /** - * Callback called after the expand transition ends. - */ - onExpandEnd?: () => void; - - /** - * Callback called before the collapse transition starts. - */ - onCollapseStart?: () => void; - - /** - * Callback called after the collapse transition ends.. - */ - onCollapseEnd?: () => void; }; export type DisclosureStateReturn = DisclosureState & DisclosureActions; export type DisclosureInitialState = unstable_IdInitialState & - Partial< - Pick< - DisclosureState, - "expanded" | "direction" | "contentSize" | "easing" | "duration" - > - > & - Pick< - DisclosureActions, - "onExpandStart" | "onExpandEnd" | "onCollapseStart" | "onCollapseEnd" - > & { + PresenceInitialState & + Partial> & { /** * Default uncontrolled state. */ @@ -122,14 +69,6 @@ export const useDisclosureState = ( defaultExpanded = false, expanded: initialExpanded, onExpandedChange, - direction = "vertical", - contentSize = 0, - duration, - easing = "cubic-bezier(0.4, 0, 0.2, 1)", - onCollapseEnd, - onCollapseStart, - onExpandEnd, - onExpandStart, } = props; const id = unstable_useIdState(); const [expanded, setExpanded] = useControllableState({ @@ -137,6 +76,7 @@ export const useDisclosureState = ( value: initialExpanded, onChange: onExpandedChange, }); + const presence = usePresenceState({ present: expanded }); const show = React.useCallback(() => setExpanded(true), [setExpanded]); const hide = React.useCallback(() => setExpanded(false), [setExpanded]); @@ -145,17 +85,10 @@ export const useDisclosureState = ( return { ...id, expanded, - direction, - contentSize, - duration, - easing, + setExpanded, show, hide, toggle, - setExpanded, - onCollapseEnd, - onCollapseStart, - onExpandEnd, - onExpandStart, + ...presence, }; }; diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts index 1e9939c03..edfb23ea5 100644 --- a/src/disclosure/__keys.ts +++ b/src/disclosure/__keys.ts @@ -2,32 +2,19 @@ export const DISCLOSURE_STATE_KEYS = [ "baseId", "unstable_idCountRef", + "isPresent", + "ref", "expanded", - "direction", - "contentSize", - "duration", - "easing", "setBaseId", "show", "hide", "toggle", "setExpanded", - "onExpandStart", - "onExpandEnd", - "onCollapseStart", - "onCollapseEnd", ] as const; export const USE_DISCLOSURE_STATE_KEYS = [ "baseId", + "present", "expanded", - "direction", - "contentSize", - "easing", - "duration", - "onExpandStart", - "onExpandEnd", - "onCollapseStart", - "onCollapseEnd", "defaultExpanded", "onExpandedChange", ] as const; diff --git a/src/disclosure/helpers.tsx b/src/disclosure/helpers.tsx index 420350c56..d5fc57b70 100644 --- a/src/disclosure/helpers.tsx +++ b/src/disclosure/helpers.tsx @@ -1,31 +1,3 @@ -export function getElementHeight( - el: React.RefObject | { current?: { scrollHeight: number } }, -): string | number { - if (!el?.current) { - return "auto"; - } - - return el.current.scrollHeight; -} - -export function getElementWidth( - el: React.RefObject | { current?: { scrollWidth: number } }, -): string | number { - if (!el?.current) { - return "auto"; - } - - return el.current.scrollWidth; -} - -// https://github.com/mui-org/material-ui/blob/da362266f7c137bf671d7e8c44c84ad5cfc0e9e2/packages/material-ui/src/styles/transitions.js#L89-L98 -export function getAutoSizeDuration(size: number | string): number { - if (!size || typeof size === "string") { - return 0; - } - - const constant = size / 36; - - // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10 - return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10); +export function getState(open?: boolean) { + return open ? "open" : "closed"; } diff --git a/src/disclosure/stories/DisclosureBasic.component.tsx b/src/disclosure/stories/DisclosureBasic.component.tsx index c776edc74..6a3dcad1d 100644 --- a/src/disclosure/stories/DisclosureBasic.component.tsx +++ b/src/disclosure/stories/DisclosureBasic.component.tsx @@ -7,25 +7,20 @@ import { useDisclosureState, } from "../../index"; -export type DisclosureProps = DisclosureInitialState & {}; +export type DisclosureBasicProps = DisclosureInitialState & {}; -export const Disclosure: React.FC = props => { - const [hasExpandStarted, setHasExpandStarted] = React.useState(false); - - const state = useDisclosureState({ - ...props, - onExpandStart: () => setHasExpandStarted(true), - onCollapseEnd: () => setHasExpandStarted(false), - }); +export const DisclosureBasic: React.FC = props => { + const state = useDisclosureState(props); + const isOpen = state.expanded || state.isPresent; return (
Show More Item 1 @@ -38,4 +33,5 @@ export const Disclosure: React.FC = props => {
); }; -export default Disclosure; + +export default DisclosureBasic; diff --git a/src/disclosure/stories/DisclosureBasic.css b/src/disclosure/stories/DisclosureBasic.css new file mode 100644 index 000000000..e2e959097 --- /dev/null +++ b/src/disclosure/stories/DisclosureBasic.css @@ -0,0 +1,48 @@ +.content { + flex-direction: column; + overflow: hidden; +} + +.content[data-state="open"] { + animation: slideDown 300ms ease-out; +} + +.content[data-state="closed"] { + animation: slideUp 300ms ease-in; +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--content-height); + } +} + +@keyframes slideUp { + from { + height: var(--content-height); + } + to { + height: 0; + } +} + +@keyframes slideRight { + from { + width: 0; + } + to { + width: var(--content-width); + } +} + +@keyframes slideLeft { + from { + width: var(--content-width); + } + to { + width: 0; + } +} diff --git a/src/disclosure/stories/DisclosureBasic.stories.tsx b/src/disclosure/stories/DisclosureBasic.stories.tsx index 80abf69e3..a35321297 100644 --- a/src/disclosure/stories/DisclosureBasic.stories.tsx +++ b/src/disclosure/stories/DisclosureBasic.stories.tsx @@ -2,14 +2,19 @@ import * as React from "react"; import { Meta, Story } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { DisclosureState } from "../DisclosureState"; +import { DisclosureState } from "../index"; import js from "./templates/DisclosureBasicJsx"; import ts from "./templates/DisclosureBasicTsx"; -import { Disclosure, DisclosureProps } from "./DisclosureBasic.component"; +import { + DisclosureBasic, + DisclosureBasicProps, +} from "./DisclosureBasic.component"; + +import "./DisclosureBasic.css"; export default { - component: Disclosure, + component: DisclosureBasic, title: "Disclosure/Basic", parameters: { layout: "centered", @@ -18,17 +23,13 @@ export default { }, } as Meta; -export const Default: Story = args => ( - -); - -export const WithTransition: Story = args => ( - +export const Default: Story = args => ( + ); export const Controlled = () => { const [value, setValue] = React.useState(false); console.log("%cvalue", "color: #997326", value); - return ; + return ; }; diff --git a/src/disclosure/stories/DisclosureHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontal.component.tsx index a1879a7b2..91c1b04a8 100644 --- a/src/disclosure/stories/DisclosureHorizontal.component.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.component.tsx @@ -10,30 +10,25 @@ import { export type DisclosureProps = DisclosureInitialState & {}; export const Disclosure: React.FC = props => { - const [hasExpandStarted, setHasExpandStarted] = React.useState(false); - - const state = useDisclosureState({ - ...props, - onExpandStart: () => setHasExpandStarted(true), - onCollapseEnd: () => setHasExpandStarted(false), - }); + const state = useDisclosureState(props); + const isOpen = state.expanded || state.isPresent; return ( -
+
Show More -
Item 1
-
Item 2
-
Item 3
-
Item 4
-
Item 5
-
Item 6
+
Item 1
+
Item 2
+
Item 3
+
Item 4
+
Item 5
+
Item 6
); diff --git a/src/disclosure/stories/DisclosureHorizontal.css b/src/disclosure/stories/DisclosureHorizontal.css new file mode 100644 index 000000000..8c49750fc --- /dev/null +++ b/src/disclosure/stories/DisclosureHorizontal.css @@ -0,0 +1,38 @@ +.root { + display: flex; + width: 100%; +} +.content { + flex-direction: row; + overflow: hidden; +} + +.content[data-state="open"] { + animation: slideRight 300ms ease-out; +} + +.item { + flex-shrink: 0; +} + +.content[data-state="closed"] { + animation: slideLeft 300ms ease-in; +} + +@keyframes slideRight { + from { + width: 0; + } + to { + width: var(--content-width); + } +} + +@keyframes slideLeft { + from { + width: var(--content-width); + } + to { + width: 0; + } +} diff --git a/src/disclosure/stories/DisclosureHorizontal.stories.tsx b/src/disclosure/stories/DisclosureHorizontal.stories.tsx index b859cfa81..6ce8ac1d4 100644 --- a/src/disclosure/stories/DisclosureHorizontal.stories.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.stories.tsx @@ -4,37 +4,28 @@ import { Meta, Story } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; import { DisclosureState } from "../DisclosureState"; +import css from "./templates/DisclosureBasicCss"; import js from "./templates/DisclosureBasicJsx"; import ts from "./templates/DisclosureBasicTsx"; import { Disclosure, DisclosureProps } from "./DisclosureHorizontal.component"; +import "./DisclosureHorizontal.css"; + export default { component: Disclosure, title: "Disclosure/Horizontal", parameters: { layout: "centered", options: { showPanel: true }, - preview: createPreviewTabs({ js, ts }), + preview: createPreviewTabs({ js, ts, css }), }, } as Meta; -export const Default: Story = args => ( - -); - -export const WithTransition: Story = args => ( - -); +export const Default: Story = args => ; export const Controlled = () => { const [value, setValue] = React.useState(false); console.log("%cvalue", "color: #997326", value); - return ( - - ); + return ; }; diff --git a/src/index.ts b/src/index.ts index 284934973..920ae026a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from "./meter"; export * from "./number-input"; export * from "./pagination"; export * from "./picker-base"; +export * from "./presence"; export * from "./progress"; export * from "./radio"; export * from "./segment"; diff --git a/src/presence/Presence.ts b/src/presence/Presence.ts new file mode 100644 index 000000000..683d9df1d --- /dev/null +++ b/src/presence/Presence.ts @@ -0,0 +1,44 @@ +import { createComponent, createHook } from "reakit-system"; +import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; +import { useForkRef } from "reakit-utils"; + +import { PRESENCE_KEYS } from "./__keys"; +import { PresenceStateReturn } from "./PresenceState"; + +export type PresenceOptions = BoxOptions & + PresenceStateReturn & { + present?: boolean; + }; + +export type PresenceHTMLProps = BoxHTMLProps & { + present?: boolean; +}; + +export type PresenceProps = PresenceOptions & PresenceHTMLProps; + +export const usePresence = createHook({ + name: "Presence", + compose: useBox, + keys: PRESENCE_KEYS, + + useOptions(options, htmlProps) { + const { present: htmlPresent } = htmlProps; + const { isPresent } = options; + const present = htmlPresent != null ? htmlPresent : isPresent; + + return { ...options, present }; + }, + + useProps(options, htmlProps) { + const { ref } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { ref: useForkRef(ref, htmlRef), ...restHtmlProps }; + }, +}); + +export const Presence = createComponent({ + as: "div", + memo: true, + useHook: usePresence, +}); diff --git a/src/presence/PresenceState.tsx b/src/presence/PresenceState.tsx new file mode 100644 index 000000000..8e90cf5ea --- /dev/null +++ b/src/presence/PresenceState.tsx @@ -0,0 +1,125 @@ +import * as React from "react"; +import { useSafeLayoutEffect } from "@chakra-ui/hooks"; + +import { getAnimationName, useStateMachine } from "./helpers"; + +export type PresenceState = { + isPresent: boolean; + ref: (node: HTMLElement) => void; +}; + +export type PresenceStateReturn = PresenceState; + +export type PresenceInitialState = { + present?: boolean; +}; + +export const usePresenceState = ( + props: PresenceInitialState = {}, +): PresenceStateReturn => { + const { present } = props; + + const [node, setNode] = React.useState(); + const stylesRef = React.useRef({} as any); + const prevPresentRef = React.useRef(present); + const prevAnimationNameRef = React.useRef("none"); + const initialState = present ? "mounted" : "unmounted"; + const [state, send] = useStateMachine(initialState, { + mounted: { + UNMOUNT: "unmounted", + ANIMATION_OUT: "unmountSuspended", + }, + unmountSuspended: { + MOUNT: "mounted", + ANIMATION_END: "unmounted", + }, + unmounted: { + MOUNT: "mounted", + }, + }); + + React.useEffect(() => { + const currentAnimationName = getAnimationName(stylesRef.current); + prevAnimationNameRef.current = + state === "mounted" ? currentAnimationName : "none"; + }, [state]); + + useSafeLayoutEffect(() => { + const styles = stylesRef.current; + const wasPresent = prevPresentRef.current; + const hasPresentChanged = wasPresent !== present; + + if (hasPresentChanged) { + const prevAnimationName = prevAnimationNameRef.current; + const currentAnimationName = getAnimationName(styles); + + if (present) { + send("MOUNT"); + } else if ( + currentAnimationName === "none" || + styles?.display === "none" + ) { + // If there is no exit animation or the element is hidden, animations won't run + // so we unmount instantly + send("UNMOUNT"); + } else { + /** + * When `present` changes to `false`, we check changes to animation-name to + * determine whether an animation has started. We chose this approach (reading + * computed styles) because there is no `animationrun` event and `animationstart` + * fires after `animation-delay` has expired which would be too late. + */ + const isAnimating = prevAnimationName !== currentAnimationName; + + if (wasPresent && isAnimating) { + send("ANIMATION_OUT"); + } else { + send("UNMOUNT"); + } + } + + prevPresentRef.current = present; + } + }, [present, send]); + + useSafeLayoutEffect(() => { + if (node) { + /** + * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` + * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we + * make sure we only trigger ANIMATION_END for the currently active animation. + */ + const handleAnimationEnd = (event: AnimationEvent) => { + const currentAnimationName = getAnimationName(stylesRef.current); + const isCurrentAnimation = currentAnimationName.includes( + event.animationName, + ); + if (event.target === node && isCurrentAnimation) { + send("ANIMATION_END"); + } + }; + const handleAnimationStart = (event: AnimationEvent) => { + if (event.target === node) { + // if animation occurred, store its name as the previous animation. + prevAnimationNameRef.current = getAnimationName(stylesRef.current); + } + }; + node.addEventListener("animationstart", handleAnimationStart); + node.addEventListener("animationcancel", handleAnimationEnd); + node.addEventListener("animationend", handleAnimationEnd); + return () => { + node.removeEventListener("animationstart", handleAnimationStart); + node.removeEventListener("animationcancel", handleAnimationEnd); + node.removeEventListener("animationend", handleAnimationEnd); + }; + } + }, [node, send]); + + return { + isPresent: ["mounted", "unmountSuspended"].includes(state), + ref: React.useCallback((node: HTMLElement) => { + if (node) stylesRef.current = getComputedStyle(node); + setNode(node); + }, []), + }; +}; diff --git a/src/presence/__keys.ts b/src/presence/__keys.ts new file mode 100644 index 000000000..279b8f0e0 --- /dev/null +++ b/src/presence/__keys.ts @@ -0,0 +1,7 @@ +// Automatically generated +export const PRESENCE_STATE_KEYS = ["isPresent", "ref"] as const; +export const USE_PRESENCE_STATE_KEYS = ["present"] as const; +export const PRESENCE_KEYS = [ + ...PRESENCE_STATE_KEYS, + ...USE_PRESENCE_STATE_KEYS, +] as const; diff --git a/src/presence/helpers.tsx b/src/presence/helpers.tsx new file mode 100644 index 000000000..78c1ff1c3 --- /dev/null +++ b/src/presence/helpers.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; + +type Machine = { [k: string]: { [k: string]: S } }; +type MachineState = keyof T; +type MachineEvent = keyof UnionToIntersection; + +// 🤯 https://fettblog.eu/typescript-union-to-intersection/ +type UnionToIntersection = (T extends any ? (x: T) => any : never) extends ( + x: infer R, +) => any + ? R + : never; + +export function useStateMachine( + initialState: MachineState, + machine: M & Machine>, +) { + return React.useReducer( + (state: MachineState, event: MachineEvent): MachineState => { + const nextState = (machine[state] as any)[event]; + return nextState ?? state; + }, + initialState, + ); +} + +export function getAnimationName(styles?: CSSStyleDeclaration) { + return styles?.animationName || "none"; +} diff --git a/src/presence/index.ts b/src/presence/index.ts new file mode 100644 index 000000000..91db5f3fd --- /dev/null +++ b/src/presence/index.ts @@ -0,0 +1,3 @@ +export * from "./__keys"; +export * from "./Presence"; +export * from "./PresenceState"; diff --git a/src/presence/stories/PresenceAnimated.component.tsx b/src/presence/stories/PresenceAnimated.component.tsx new file mode 100644 index 000000000..bd9e3fd56 --- /dev/null +++ b/src/presence/stories/PresenceAnimated.component.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; + +import { Presence, usePresenceState } from "../../index"; + +export type PresenceAnimatedProps = {}; + +export const PresenceAnimated = () => { + const [open, setOpen] = React.useState(true); + + const state = usePresenceState({ present: open }); + + return ( + <> + + + {state.isPresent ? ( + + Content + + ) : null} + + ); +}; diff --git a/src/presence/stories/PresenceAnimated.css b/src/presence/stories/PresenceAnimated.css new file mode 100644 index 000000000..6028f7d4a --- /dev/null +++ b/src/presence/stories/PresenceAnimated.css @@ -0,0 +1,25 @@ +.content[data-state="open"] { + animation: fadeIn 3s ease-out; +} + +.content[data-state="closed"] { + animation: fadeOut 3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} diff --git a/src/presence/stories/PresenceAnimated.stories.tsx b/src/presence/stories/PresenceAnimated.stories.tsx new file mode 100644 index 000000000..41e9b6863 --- /dev/null +++ b/src/presence/stories/PresenceAnimated.stories.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/PresenceAnimatedCss"; +import js from "./templates/PresenceAnimatedJsx"; +import ts from "./templates/PresenceAnimatedTsx"; +import { + PresenceAnimated, + PresenceAnimatedProps, +} from "./PresenceAnimated.component"; + +import "./PresenceAnimated.css"; + +export default { + component: PresenceAnimated, + title: "Presence/Animated", + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ( + +); +Default.args = {}; diff --git a/src/presence/stories/PresenceBasic.component.tsx b/src/presence/stories/PresenceBasic.component.tsx new file mode 100644 index 000000000..f41676393 --- /dev/null +++ b/src/presence/stories/PresenceBasic.component.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +import { Presence, usePresenceState } from "../../index"; + +export type PresenceBasicProps = {}; + +export const PresenceBasic = () => { + const [open, setOpen] = React.useState(true); + + const state = usePresenceState({ present: open }); + + return ( + <> + + + {state.isPresent ? Content : null} + + ); +}; diff --git a/src/presence/stories/PresenceBasic.stories.tsx b/src/presence/stories/PresenceBasic.stories.tsx new file mode 100644 index 000000000..0960d5e1c --- /dev/null +++ b/src/presence/stories/PresenceBasic.stories.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/PresenceBasicJsx"; +import ts from "./templates/PresenceBasicTsx"; +import { PresenceBasic, PresenceBasicProps } from "./PresenceBasic.component"; + +export default { + component: PresenceBasic, + title: "Presence/Basic", + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = args => ( + +); +Default.args = {}; From 7b41925bfb6885dafff49564f57c9cbc155442a9 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Fri, 12 Nov 2021 20:15:53 +0530 Subject: [PATCH 02/13] =?UTF-8?q?refactor(disclosure):=20=E2=99=BB?= =?UTF-8?q?=EF=B8=8F=20update=20css=20&=20stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/disclosure/stories/DisclosureBasic.css | 18 ------------------ .../stories/DisclosureBasic.stories.tsx | 3 ++- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/src/disclosure/stories/DisclosureBasic.css b/src/disclosure/stories/DisclosureBasic.css index e2e959097..d7cb78c13 100644 --- a/src/disclosure/stories/DisclosureBasic.css +++ b/src/disclosure/stories/DisclosureBasic.css @@ -28,21 +28,3 @@ height: 0; } } - -@keyframes slideRight { - from { - width: 0; - } - to { - width: var(--content-width); - } -} - -@keyframes slideLeft { - from { - width: var(--content-width); - } - to { - width: 0; - } -} diff --git a/src/disclosure/stories/DisclosureBasic.stories.tsx b/src/disclosure/stories/DisclosureBasic.stories.tsx index a35321297..449dca7f5 100644 --- a/src/disclosure/stories/DisclosureBasic.stories.tsx +++ b/src/disclosure/stories/DisclosureBasic.stories.tsx @@ -4,6 +4,7 @@ import { Meta, Story } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; import { DisclosureState } from "../index"; +import css from "./templates/DisclosureBasicCss"; import js from "./templates/DisclosureBasicJsx"; import ts from "./templates/DisclosureBasicTsx"; import { @@ -19,7 +20,7 @@ export default { parameters: { layout: "centered", options: { showPanel: true }, - preview: createPreviewTabs({ js, ts }), + preview: createPreviewTabs({ js, ts, css }), }, } as Meta; From 0729ce674f702391426531681b6464b435ab32cc Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Mon, 15 Nov 2021 21:45:27 +0530 Subject: [PATCH 03/13] =?UTF-8?q?feat(dialog):=20=E2=9C=A8=20add=20dialog?= =?UTF-8?q?=20with=20presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + src/dialog/Dialog.tsx | 222 ++++++++++++++++++ src/dialog/DialogBackdrop.tsx | 69 ++++++ src/dialog/DialogDisclosure.ts | 86 +++++++ src/dialog/DialogState.ts | 55 +++++ src/dialog/__keys.ts | 36 +++ src/dialog/helpers/DialogBackdropContext.ts | 5 + src/dialog/helpers/index.ts | 13 + src/dialog/helpers/useDisableHoverOutside.ts | 28 +++ src/dialog/helpers/useDisclosureRef.ts | 60 +++++ src/dialog/helpers/useEventListenerOutside.ts | 89 +++++++ src/dialog/helpers/useFocusOnBlur.ts | 55 +++++ src/dialog/helpers/useFocusOnChildUnmount.ts | 41 ++++ src/dialog/helpers/useFocusOnHide.ts | 72 ++++++ src/dialog/helpers/useFocusOnShow.ts | 64 +++++ src/dialog/helpers/useFocusTrap.ts | 101 ++++++++ src/dialog/helpers/useHideOnClickOutside.ts | 69 ++++++ src/dialog/helpers/useNestedDialogs.tsx | 118 ++++++++++ src/dialog/helpers/usePortalRef.ts | 22 ++ src/dialog/helpers/usePreventBodyScroll.ts | 21 ++ src/dialog/index.ts | 6 + src/dialog/stories/DialogBasic.component.tsx | 46 ++++ src/dialog/stories/DialogBasic.css | 89 +++++++ src/dialog/stories/DialogBasic.stories.tsx | 32 +++ .../{DisclosureButton.tsx => Disclosure.tsx} | 33 ++- src/disclosure/DisclosureContent.tsx | 18 +- src/disclosure/DisclosureState.ts | 41 ++-- src/disclosure/__keys.ts | 15 +- src/disclosure/helpers.tsx | 3 - src/disclosure/index.ts | 2 +- .../stories/DisclosureBasic.component.tsx | 6 +- src/disclosure/stories/DisclosureBasic.css | 4 +- .../stories/DisclosureBasic.stories.tsx | 4 +- .../DisclosureHorizontal.component.tsx | 53 +++-- .../stories/DisclosureHorizontal.css | 4 +- .../stories/DisclosureHorizontal.stories.tsx | 15 +- src/index.ts | 1 + src/presence/PresenceChildren.tsx | 32 +++ src/presence/PresenceState.tsx | 9 +- src/presence/helpers.tsx | 2 + src/presence/index.ts | 1 + yarn.lock | 10 + 42 files changed, 1554 insertions(+), 100 deletions(-) create mode 100644 src/dialog/Dialog.tsx create mode 100644 src/dialog/DialogBackdrop.tsx create mode 100644 src/dialog/DialogDisclosure.ts create mode 100644 src/dialog/DialogState.ts create mode 100644 src/dialog/__keys.ts create mode 100644 src/dialog/helpers/DialogBackdropContext.ts create mode 100644 src/dialog/helpers/index.ts create mode 100644 src/dialog/helpers/useDisableHoverOutside.ts create mode 100644 src/dialog/helpers/useDisclosureRef.ts create mode 100644 src/dialog/helpers/useEventListenerOutside.ts create mode 100644 src/dialog/helpers/useFocusOnBlur.ts create mode 100644 src/dialog/helpers/useFocusOnChildUnmount.ts create mode 100644 src/dialog/helpers/useFocusOnHide.ts create mode 100644 src/dialog/helpers/useFocusOnShow.ts create mode 100644 src/dialog/helpers/useFocusTrap.ts create mode 100644 src/dialog/helpers/useHideOnClickOutside.ts create mode 100644 src/dialog/helpers/useNestedDialogs.tsx create mode 100644 src/dialog/helpers/usePortalRef.ts create mode 100644 src/dialog/helpers/usePreventBodyScroll.ts create mode 100644 src/dialog/index.ts create mode 100644 src/dialog/stories/DialogBasic.component.tsx create mode 100644 src/dialog/stories/DialogBasic.css create mode 100644 src/dialog/stories/DialogBasic.stories.tsx rename src/disclosure/{DisclosureButton.tsx => Disclosure.tsx} (58%) delete mode 100644 src/disclosure/helpers.tsx create mode 100644 src/presence/PresenceChildren.tsx diff --git a/package.json b/package.json index dfdc2cf2e..0c4038ad1 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,8 @@ "@react-aria/interactions": "^3.6.0", "@react-aria/spinbutton": "^3.0.1", "@react-aria/utils": "^3.9.0", + "@types/body-scroll-lock": "^3.1.0", + "body-scroll-lock": "^4.0.0-beta.0", "date-fns": "^2.25.0", "raf": "^3.4.1", "reakit-system": "^0.15.2", diff --git a/src/dialog/Dialog.tsx b/src/dialog/Dialog.tsx new file mode 100644 index 000000000..84180c904 --- /dev/null +++ b/src/dialog/Dialog.tsx @@ -0,0 +1,222 @@ +import * as React from "react"; +import { createComponent, createHook, useCreateElement } from "reakit-system"; +import { Portal } from "reakit"; +import { useForkRef, useLiveRef } from "reakit-utils"; +import { useWarning, warning } from "reakit-warning"; + +import { + DisclosureContentHTMLProps, + DisclosureContentOptions, + useDisclosureContent, +} from "../disclosure/DisclosureContent"; + +import { DIALOG_KEYS } from "./__keys"; +import { DialogStateReturn } from "./DialogState"; +import { + DialogBackdropContext, + useDisableHoverOutside, + useDisclosureRef, + useFocusOnBlur, + useFocusOnChildUnmount, + useFocusOnHide, + useFocusOnShow, + useFocusTrap, + useHideOnClickOutside, + useNestedDialogs, + usePreventBodyScroll, +} from "./helpers"; + +export type DialogOptions = DisclosureContentOptions & + Pick, "modal" | "hide" | "disclosureRef"> & + Pick & { + /** + * When enabled, user can hide the dialog by pressing `Escape`. + */ + hideOnEsc?: boolean; + + /** + * When enabled, user can hide the dialog by clicking outside it. + */ + hideOnClickOutside?: boolean; + + /** + * When enabled, user can't scroll on body when the dialog is visible. + * This option doesn't work if the dialog isn't modal. + */ + preventBodyScroll?: boolean; + + /** + * The element that will be focused when the dialog shows. + * When not set, the first tabbable element within the dialog will be used. + */ + unstable_initialFocusRef?: React.RefObject; + + /** + * The element that will be focused when the dialog hides. + * When not set, the disclosure component will be used. + */ + unstable_finalFocusRef?: React.RefObject; + + /** + * Whether or not the dialog should be a child of its parent. + * Opening a nested orphan dialog will close its parent dialog if + * `hideOnClickOutside` is set to `true` on the parent. + * It will be set to `false` if `modal` is `false`. + */ + unstable_orphan?: boolean; + + /** + * Whether or not to move focus when the dialog shows. + * @private + */ + unstable_autoFocusOnShow?: boolean; + + /** + * Whether or not to move focus when the dialog hides. + * @private + */ + unstable_autoFocusOnHide?: boolean; + }; + +export type DialogHTMLProps = DisclosureContentHTMLProps; + +export type DialogProps = DialogOptions & DialogHTMLProps; + +export const useDialog = createHook({ + name: "Dialog", + compose: useDisclosureContent, + keys: DIALOG_KEYS, + + useOptions({ + modal = true, + hideOnEsc = true, + hideOnClickOutside = true, + preventBodyScroll = modal, + unstable_autoFocusOnShow = true, + unstable_autoFocusOnHide = true, + unstable_orphan, + ...options + }) { + return { + modal, + hideOnEsc, + hideOnClickOutside, + preventBodyScroll: modal && preventBodyScroll, + unstable_autoFocusOnShow, + unstable_autoFocusOnHide, + unstable_orphan: modal && unstable_orphan, + ...options, + }; + }, + + useProps( + options, + { + ref: htmlRef, + onKeyDown: htmlOnKeyDown, + onBlur: htmlOnBlur, + wrapElement: htmlWrapElement, + tabIndex, + ...htmlProps + }, + ) { + const dialog = React.useRef(null); + const backdrop = React.useContext(DialogBackdropContext); + const hasBackdrop = backdrop && backdrop === options.baseId; + const disclosure = useDisclosureRef(dialog, options); + const onKeyDownRef = useLiveRef(htmlOnKeyDown); + const onBlurRef = useLiveRef(htmlOnBlur); + const focusOnBlur = useFocusOnBlur(dialog, options); + const { dialogs, visibleModals, wrap } = useNestedDialogs(dialog, options); + // VoiceOver/Safari accepts only one `aria-modal` container, so if there + // are visible child modals, then we don't want to set aria-modal on the + // parent modal (this component). + const modal = options.modal && !visibleModals.length ? true : undefined; + + usePreventBodyScroll(dialog, options); + useFocusTrap(dialog, visibleModals, options); + useFocusOnChildUnmount(dialog, options); + useFocusOnShow(dialog, dialogs, options); + useFocusOnHide(dialog, disclosure, options); + useHideOnClickOutside(dialog, disclosure, dialogs, options); + useDisableHoverOutside(dialog, dialogs, options); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + onKeyDownRef.current?.(event); + + if (event.defaultPrevented) return; + if (event.key !== "Escape") return; + if (!options.hideOnEsc) return; + if (!options.hide) { + warning( + true, + "`hideOnEsc` prop is truthy, but `hide` prop wasn't provided.", + "See https://reakit.io/docs/dialog", + dialog.current, + ); + return; + } + event.stopPropagation(); + + options.hide(); + }, + [options.hideOnEsc, options.hide], + ); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + onBlurRef.current?.(event); + + focusOnBlur(event); + }, + [focusOnBlur, onBlurRef], + ); + + const wrapElement = React.useCallback( + (element: React.ReactNode) => { + element = wrap(element); + + if (options.modal && !hasBackdrop) { + element = {element}; + } + + if (htmlWrapElement) { + element = htmlWrapElement(element); + } + + // return ( + // // Prevents Menu > Dialog > Menu to behave as a sub menu + // {element} + // ); + return element; + }, + [wrap, options.modal, hasBackdrop, htmlWrapElement], + ); + + return { + ref: useForkRef(dialog, htmlRef), + role: "dialog", + tabIndex: tabIndex ?? -1, + "aria-modal": modal, + "data-dialog": true, + onKeyDown, + onBlur, + wrapElement, + ...htmlProps, + }; + }, +}); + +export const Dialog = createComponent({ + as: "div", + useHook: useDialog, + useCreateElement: (type, props, children) => { + useWarning( + !props["aria-label"] && !props["aria-labelledby"], + "You should provide either `aria-label` or `aria-labelledby` props.", + "See https://reakit.io/docs/dialog", + ); + return useCreateElement(type, props, children); + }, +}); diff --git a/src/dialog/DialogBackdrop.tsx b/src/dialog/DialogBackdrop.tsx new file mode 100644 index 000000000..80863f592 --- /dev/null +++ b/src/dialog/DialogBackdrop.tsx @@ -0,0 +1,69 @@ +import * as React from "react"; +import { createComponent } from "reakit-system/createComponent"; +import { createHook } from "reakit-system/createHook"; +import { Portal } from "reakit"; + +import { + DisclosureContentHTMLProps, + DisclosureContentOptions, + useDisclosureContent, +} from "../disclosure/DisclosureContent"; + +import { DIALOG_BACKDROP_KEYS } from "./__keys"; +import { DialogStateReturn } from "./DialogState"; +import { DialogBackdropContext } from "./helpers"; + +export type DialogBackdropOptions = DisclosureContentOptions & + Pick, "modal">; + +export type DialogBackdropHTMLProps = DisclosureContentHTMLProps; + +export type DialogBackdropProps = DialogBackdropOptions & + DialogBackdropHTMLProps; + +export const useDialogBackdrop = createHook< + DialogBackdropOptions, + DialogBackdropHTMLProps +>({ + name: "DialogBackdrop", + compose: useDisclosureContent, + keys: DIALOG_BACKDROP_KEYS, + + useOptions({ modal = true, ...options }) { + return { modal, ...options }; + }, + + useProps(options, { wrapElement: htmlWrapElement, ...htmlProps }) { + const wrapElement = React.useCallback( + (element: React.ReactNode) => { + if (options.modal) { + element = ( + + + {element} + + + ); + } + if (htmlWrapElement) { + return htmlWrapElement(element); + } + return element; + }, + [options.modal, options.baseId, htmlWrapElement], + ); + + return { + id: undefined, + "data-dialog-ref": options.baseId, + wrapElement, + ...htmlProps, + }; + }, +}); + +export const DialogBackdrop = createComponent({ + as: "div", + memo: true, + useHook: useDialogBackdrop, +}); diff --git a/src/dialog/DialogDisclosure.ts b/src/dialog/DialogDisclosure.ts new file mode 100644 index 000000000..2caac037f --- /dev/null +++ b/src/dialog/DialogDisclosure.ts @@ -0,0 +1,86 @@ +import * as React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { useForkRef, useLiveRef } from "reakit-utils"; +import { warning } from "reakit-warning"; +import { useSafeLayoutEffect } from "@chakra-ui/hooks"; + +import { + DisclosureHTMLProps, + DisclosureOptions, + useDisclosure, +} from "../disclosure"; + +import { DIALOG_DISCLOSURE_KEYS } from "./__keys"; +import { DialogStateReturn } from "./DialogState"; + +export type DialogDisclosureOptions = DisclosureOptions & + Pick, "disclosureRef"> & + Pick; + +export type DialogDisclosureHTMLProps = DisclosureHTMLProps; + +export type DialogDisclosureProps = DialogDisclosureOptions & + DialogDisclosureHTMLProps; + +export const useDialogDisclosure = createHook< + DialogDisclosureOptions, + DialogDisclosureHTMLProps +>({ + name: "DialogDisclosure", + compose: useDisclosure, + keys: DIALOG_DISCLOSURE_KEYS, + + useProps(options, { ref: htmlRef, onClick: htmlOnClick, ...htmlProps }) { + const ref = React.useRef(null); + const onClickRef = useLiveRef(htmlOnClick); + const [expanded, setExpanded] = React.useState(false); + const disclosureRef = options.disclosureRef; + + // aria-expanded may be used for styling purposes, so we useLayoutEffect + useSafeLayoutEffect(() => { + const element = ref.current; + + warning( + !element, + "Can't determine whether the element is the current disclosure because `ref` wasn't passed to the component", + "See https://reakit.io/docs/dialog", + ); + + if (disclosureRef && !disclosureRef.current) { + disclosureRef.current = element; + } + + const isCurrentDisclosure = + !disclosureRef?.current || disclosureRef.current === element; + + setExpanded(!!options.visible && isCurrentDisclosure); + }, [options.visible, disclosureRef]); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickRef.current?.(event); + + if (event.defaultPrevented) return; + + if (disclosureRef) { + disclosureRef.current = event.currentTarget; + } + }, + [disclosureRef, onClickRef], + ); + + return { + ref: useForkRef(ref, htmlRef), + "aria-haspopup": "dialog", + "aria-expanded": expanded, + onClick, + ...htmlProps, + }; + }, +}); + +export const DialogDisclosure = createComponent({ + as: "button", + memo: true, + useHook: useDialogDisclosure, +}); diff --git a/src/dialog/DialogState.ts b/src/dialog/DialogState.ts new file mode 100644 index 000000000..df13fbf98 --- /dev/null +++ b/src/dialog/DialogState.ts @@ -0,0 +1,55 @@ +import * as React from "react"; + +import { + DisclosureActions, + DisclosureInitialState, + DisclosureState, + DisclosureStateReturn, + useDisclosureState, +} from "../disclosure"; + +export type DialogState = DisclosureState & { + /** + * Toggles Dialog's `modal` state. + * - Non-modal: `preventBodyScroll` doesn't work and focus is free. + * - Modal: `preventBodyScroll` is automatically enabled, focus is + * trapped within the dialog and the dialog is rendered within a `Portal` + * by default. + */ + modal: boolean; + + /** + * @private + */ + disclosureRef: React.MutableRefObject; +}; + +export type DialogActions = DisclosureActions & { + /** + * Sets `modal`. + */ + setModal: React.Dispatch>; +}; + +export type DialogInitialState = DisclosureInitialState & + Partial>; + +export type DialogStateReturn = DisclosureStateReturn & + DialogState & + DialogActions; + +export function useDialogState( + props: DialogInitialState = {}, +): DialogStateReturn { + const { modal: initialModal = true, ...restProps } = props; + const disclosure = useDisclosureState(restProps); + const [modal, setModal] = React.useState(initialModal); + const disclosureRef = React.useRef(null); + + return { + ...disclosure, + modal, + setModal, + disclosureRef: disclosureRef, + }; +} diff --git a/src/dialog/__keys.ts b/src/dialog/__keys.ts new file mode 100644 index 000000000..40a488238 --- /dev/null +++ b/src/dialog/__keys.ts @@ -0,0 +1,36 @@ +// Automatically generated +export const USE_DIALOG_STATE_KEYS = [ + "baseId", + "present", + "visible", + "defaultVisible", + "onVisibleChange", + "modal", +] as const; +export const DIALOG_STATE_KEYS = [ + "baseId", + "unstable_idCountRef", + "isPresent", + "visible", + "setBaseId", + "show", + "hide", + "toggle", + "setVisible", + "modal", + "disclosureRef", + "setModal", +] as const; +export const DIALOG_KEYS = [ + ...DIALOG_STATE_KEYS, + "hideOnEsc", + "hideOnClickOutside", + "preventBodyScroll", + "unstable_initialFocusRef", + "unstable_finalFocusRef", + "unstable_orphan", + "unstable_autoFocusOnShow", + "unstable_autoFocusOnHide", +] as const; +export const DIALOG_BACKDROP_KEYS = DIALOG_STATE_KEYS; +export const DIALOG_DISCLOSURE_KEYS = DIALOG_BACKDROP_KEYS; diff --git a/src/dialog/helpers/DialogBackdropContext.ts b/src/dialog/helpers/DialogBackdropContext.ts new file mode 100644 index 000000000..6c090a44a --- /dev/null +++ b/src/dialog/helpers/DialogBackdropContext.ts @@ -0,0 +1,5 @@ +import * as React from "react"; + +export const DialogBackdropContext = React.createContext( + undefined, +); diff --git a/src/dialog/helpers/index.ts b/src/dialog/helpers/index.ts new file mode 100644 index 000000000..d1990d0c6 --- /dev/null +++ b/src/dialog/helpers/index.ts @@ -0,0 +1,13 @@ +export * from "./DialogBackdropContext"; +export * from "./useDisableHoverOutside"; +export * from "./useDisclosureRef"; +export * from "./useEventListenerOutside"; +export * from "./useFocusOnBlur"; +export * from "./useFocusOnChildUnmount"; +export * from "./useFocusOnHide"; +export * from "./useFocusOnShow"; +export * from "./useFocusTrap"; +export * from "./useHideOnClickOutside"; +export * from "./useNestedDialogs"; +export * from "./usePortalRef"; +export * from "./usePreventBodyScroll"; diff --git a/src/dialog/helpers/useDisableHoverOutside.ts b/src/dialog/helpers/useDisableHoverOutside.ts new file mode 100644 index 000000000..bdacdb351 --- /dev/null +++ b/src/dialog/helpers/useDisableHoverOutside.ts @@ -0,0 +1,28 @@ +import * as React from "react"; + +import { DialogOptions } from "../Dialog"; + +import { useEventListenerOutside } from "./useEventListenerOutside"; + +export function useDisableHoverOutside( + portalRef: React.RefObject, + nestedDialogs: Array>, + options: DialogOptions, +) { + const useEvent = (eventType: string) => + useEventListenerOutside( + portalRef, + { current: null }, + nestedDialogs, + eventType, + event => { + event.stopPropagation(); + event.preventDefault(); + }, + options.visible && options.modal, + true, + ); + useEvent("mouseover"); + useEvent("mousemove"); + useEvent("mouseout"); +} diff --git a/src/dialog/helpers/useDisclosureRef.ts b/src/dialog/helpers/useDisclosureRef.ts new file mode 100644 index 000000000..d84d5ea63 --- /dev/null +++ b/src/dialog/helpers/useDisclosureRef.ts @@ -0,0 +1,60 @@ +import * as React from "react"; +import { getDocument } from "reakit-utils/getDocument"; +import { isButton } from "reakit-utils/isButton"; + +import { DialogOptions } from "../Dialog"; + +export function useDisclosureRef( + dialogRef: React.RefObject, + options: DialogOptions, +) { + const ref = React.useRef(null); + + React.useEffect(() => { + if (options.visible && !options.isPresent) { + // We get the last focused element before the dialog opens, so we can move + // focus back to it when the dialog closes. + const onFocus = (event: FocusEvent) => { + const target = event.target as HTMLElement; + + if ("focus" in target) { + ref.current = target; + + if (options.disclosureRef) { + options.disclosureRef.current = target; + } + } + }; + + const document = getDocument(dialogRef.current); + document.addEventListener("focusin", onFocus); + + return () => document.removeEventListener("focusin", onFocus); + } + }, [options.visible, options.isPresent, options.disclosureRef, dialogRef]); + + React.useEffect(() => { + if (!options.visible && options.isPresent) { + // Safari and Firefox on MacOS don't focus on buttons on mouse down. + // Instead, they focus on the closest focusable parent (ultimately, the + // body element). This works around that by preventing that behavior and + // forcing focus on the disclosure button. Otherwise, we wouldn't be able + // to close the dialog by clicking again on the disclosure. + const onMouseDown = (event: MouseEvent) => { + const element = event.currentTarget as HTMLElement; + + if (!isButton(element)) return; + + event.preventDefault(); + element.focus(); + }; + + const disclosure = options.disclosureRef?.current || ref.current; + disclosure?.addEventListener("mousedown", onMouseDown); + + return () => disclosure?.removeEventListener("mousedown", onMouseDown); + } + }, [options.visible, options.isPresent, options.disclosureRef]); + + return options.disclosureRef || ref; +} diff --git a/src/dialog/helpers/useEventListenerOutside.ts b/src/dialog/helpers/useEventListenerOutside.ts new file mode 100644 index 000000000..70a9defe5 --- /dev/null +++ b/src/dialog/helpers/useEventListenerOutside.ts @@ -0,0 +1,89 @@ +import * as React from "react"; +import { contains } from "reakit-utils/contains"; +import { getDocument } from "reakit-utils/getDocument"; +import { useLiveRef } from "reakit-utils/useLiveRef"; +import { warning } from "reakit-warning"; + +import { isFocusTrap } from "./useFocusTrap"; + +function dialogContains(target: Element) { + return (dialogRef: React.RefObject) => { + const dialog = dialogRef.current; + if (!dialog) return false; + if (contains(dialog, target)) return true; + const document = getDocument(dialog); + const backdrop = document.querySelector(`[data-dialog-ref="${dialog.id}"]`); + if (backdrop) { + return contains(backdrop, target); + } + return false; + }; +} + +function isDisclosure(target: Element, disclosure: HTMLElement) { + return contains(disclosure, target); +} + +function isInDocument(target: Element) { + const document = getDocument(target); + if (target.tagName === "HTML") { + return true; + } + return contains(document.body, target); +} + +export function useEventListenerOutside( + containerRef: React.RefObject, + disclosureRef: React.RefObject, + nestedDialogs: Array>, + eventType: string, + listener?: (e: Event) => void, + shouldListen?: boolean, + capture?: boolean, +) { + const listenerRef = useLiveRef(listener); + + React.useEffect(() => { + if (!shouldListen) return undefined; + + const onEvent = (event: Event) => { + if (!listenerRef.current) return; + const container = containerRef.current; + const disclosure = disclosureRef.current; + const target = event.target as Element; + if (!container) { + warning( + true, + "Can't detect events outside dialog because `ref` wasn't passed to component.", + "See https://reakit.io/docs/dialog", + ); + return; + } + // When an element is unmounted right after it receives focus, the focus + // event is triggered after that, when the element isn't part of the + // current document anymore. So we ignore it. + if (!isInDocument(target)) return; + // Event inside dialog + if (contains(container, target)) return; + // Event on disclosure + if (disclosure && isDisclosure(target, disclosure)) return; + // Event inside a nested dialog or focus trap + if (isFocusTrap(target) || nestedDialogs.some(dialogContains(target))) { + return; + } + listenerRef.current(event); + }; + + const document = getDocument(containerRef.current); + document.addEventListener(eventType, onEvent, capture); + return () => document.removeEventListener(eventType, onEvent, capture); + }, [ + containerRef, + disclosureRef, + nestedDialogs, + eventType, + shouldListen, + listenerRef, + capture, + ]); +} diff --git a/src/dialog/helpers/useFocusOnBlur.ts b/src/dialog/helpers/useFocusOnBlur.ts new file mode 100644 index 000000000..c783d30a4 --- /dev/null +++ b/src/dialog/helpers/useFocusOnBlur.ts @@ -0,0 +1,55 @@ +import * as React from "react"; +import { getActiveElement } from "reakit-utils/getActiveElement"; +import { getDocument } from "reakit-utils/getDocument"; +import { getNextActiveElementOnBlur } from "reakit-utils/getNextActiveElementOnBlur"; +import { useIsomorphicEffect } from "reakit-utils/useIsomorphicEffect"; +import { warning } from "reakit-warning"; + +import { DialogOptions } from "../Dialog"; + +function isActualElement(element?: Element | null) { + return ( + element && + element.tagName && + element.tagName !== "HTML" && + element !== getDocument(element).body + ); +} + +export function useFocusOnBlur( + dialogRef: React.RefObject, + options: DialogOptions, +) { + const [blurred, scheduleFocus] = React.useReducer((n: number) => n + 1, 0); + + useIsomorphicEffect(() => { + const dialog = dialogRef.current; + if (!options.visible) return; + if (!blurred) return; + // After blur, if the active element isn't an actual element, this probably + // means that element.blur() was called on an element inside the dialog. + // In this case, the browser will automatically focus the body element. + // So we move focus back to the dialog. + if (!isActualElement(getActiveElement(dialog))) { + warning( + !dialog, + "Can't focus dialog after a nested element got blurred because `ref` wasn't passed to the component", + "See https://reakit.io/docs/dialog", + ); + dialog?.focus(); + } + }, [blurred, dialogRef]); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + if (!options.visible) return; + const nextActiveElement = getNextActiveElementOnBlur(event); + if (!isActualElement(nextActiveElement)) { + scheduleFocus(); + } + }, + [options.visible], + ); + + return onBlur; +} diff --git a/src/dialog/helpers/useFocusOnChildUnmount.ts b/src/dialog/helpers/useFocusOnChildUnmount.ts new file mode 100644 index 000000000..0e8388110 --- /dev/null +++ b/src/dialog/helpers/useFocusOnChildUnmount.ts @@ -0,0 +1,41 @@ +import * as React from "react"; +import { getActiveElement } from "reakit-utils/getActiveElement"; +import { getDocument } from "reakit-utils/getDocument"; +import { isEmpty } from "reakit-utils/isEmpty"; + +import { DialogOptions } from "../Dialog"; + +/** + * When the focused child gets removed from the DOM, we make sure to move focus + * to the dialog. + */ +export function useFocusOnChildUnmount( + dialogRef: React.RefObject, + options: DialogOptions, +) { + React.useEffect(() => { + const dialog = dialogRef.current; + if (!options.visible || !dialog) return undefined; + + const observer = new MutationObserver(mutations => { + const [{ target }] = mutations; + // If target is not this dialog, then this observer was triggered by a + // nested dialog, so we just ignore it here and let the nested dialog + // handle it there. + if (target !== dialog) return; + const document = getDocument(dialog); + const activeElement = getActiveElement(dialog); + // We can check if the current focused element is the document body. On + // IE 11, it's an empty object when the current document is in a frame or + // iframe. + if (activeElement === document.body || isEmpty(activeElement)) { + dialog.focus(); + } + }); + + observer.observe(dialog, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + }; + }, [options.visible, dialogRef]); +} diff --git a/src/dialog/helpers/useFocusOnHide.ts b/src/dialog/helpers/useFocusOnHide.ts new file mode 100644 index 000000000..bfc3c97ec --- /dev/null +++ b/src/dialog/helpers/useFocusOnHide.ts @@ -0,0 +1,72 @@ +import * as React from "react"; +import { contains } from "reakit-utils/contains"; +import { ensureFocus } from "reakit-utils/ensureFocus"; +import { getActiveElement } from "reakit-utils/getActiveElement"; +import { getDocument } from "reakit-utils/getDocument"; +import { isTabbable } from "reakit-utils/tabbable"; +import { useUpdateEffect } from "reakit-utils/useUpdateEffect"; +import { warning } from "reakit-warning"; + +import { DialogOptions } from "../Dialog"; + +function hidByFocusingAnotherElement(dialogRef: React.RefObject) { + const dialog = dialogRef.current; + if (!dialog) return false; + + const activeElement = getActiveElement(dialog); + + if (!activeElement) return false; + if (contains(dialog, activeElement)) return false; + if (isTabbable(activeElement)) return true; + if (activeElement.getAttribute("data-dialog") === "true") return true; + + return false; +} + +export function useFocusOnHide( + dialogRef: React.RefObject, + disclosureRef: React.RefObject, + options: DialogOptions, +) { + const shouldFocus = options.unstable_autoFocusOnHide && !options.visible; + + useUpdateEffect(() => { + if (!shouldFocus) return; + if (!options.isPresent) return; + + // Hide was triggered by a click/focus on a tabbable element outside + // the dialog or on another dialog. We won't change focus then. + if (hidByFocusingAnotherElement(dialogRef)) { + return; + } + + const finalFocusEl = + options.unstable_finalFocusRef?.current || disclosureRef.current; + + if (finalFocusEl) { + if (finalFocusEl.id) { + const document = getDocument(finalFocusEl); + const compositeElement = document.querySelector( + `[aria-activedescendant='${finalFocusEl.id}']`, + ); + + if (compositeElement) { + ensureFocus(compositeElement); + + return; + } + } + + ensureFocus(finalFocusEl); + + return; + } + + warning( + true, + "Can't return focus after closing dialog. Either render a disclosure component or provide a `unstable_finalFocusRef` prop.", + "See https://reakit.io/docs/dialog", + dialogRef.current, + ); + }, [shouldFocus, options.isPresent, dialogRef, disclosureRef]); +} diff --git a/src/dialog/helpers/useFocusOnShow.ts b/src/dialog/helpers/useFocusOnShow.ts new file mode 100644 index 000000000..596d34f6f --- /dev/null +++ b/src/dialog/helpers/useFocusOnShow.ts @@ -0,0 +1,64 @@ +import * as React from "react"; +import { + ensureFocus, + getFirstTabbableIn, + hasFocusWithin, + useUpdateEffect, +} from "reakit-utils"; +import { warning } from "reakit-warning"; + +import { DialogOptions } from "../Dialog"; + +export function useFocusOnShow( + dialogRef: React.RefObject, + nestedDialogs: Array>, + options: DialogOptions, +) { + const initialFocusRef = options.unstable_initialFocusRef; + const shouldFocus = options.visible && options.unstable_autoFocusOnShow; + + useUpdateEffect(() => { + const dialog = dialogRef.current; + + warning( + !!shouldFocus && !dialog, + "[reakit/Dialog]", + "Can't set initial focus on dialog because `ref` wasn't passed to the dialog element.", + "See https://reakit.io/docs/dialog", + ); + + if (!shouldFocus) return; + if (!dialog) return; + if (!options.isPresent) return; + + // If there're nested open dialogs, let them handle focus + if (nestedDialogs.some(child => child.current && !child.current.hidden)) { + return; + } + + if (initialFocusRef?.current) { + initialFocusRef.current.focus({ preventScroll: true }); + } else { + const tabbable = getFirstTabbableIn(dialog, true); + const isActive = () => hasFocusWithin(dialog); + if (tabbable) { + ensureFocus(tabbable, { preventScroll: true, isActive }); + } else { + ensureFocus(dialog, { preventScroll: true, isActive }); + warning( + dialog.tabIndex === undefined || dialog.tabIndex < 0, + "It's recommended to have at least one tabbable element inside dialog. The dialog element has been automatically focused.", + "If this is the intended behavior, pass `tabIndex={0}` to the dialog element to disable this warning.", + "See https://reakit.io/docs/dialog/#initial-focus", + dialog, + ); + } + } + }, [ + dialogRef, + shouldFocus, + options.isPresent, + nestedDialogs, + initialFocusRef, + ]); +} diff --git a/src/dialog/helpers/useFocusTrap.ts b/src/dialog/helpers/useFocusTrap.ts new file mode 100644 index 000000000..62d18fea8 --- /dev/null +++ b/src/dialog/helpers/useFocusTrap.ts @@ -0,0 +1,101 @@ +import * as React from "react"; +import { getDocument } from "reakit-utils/getDocument"; +import { getFirstTabbableIn, getLastTabbableIn } from "reakit-utils/tabbable"; +import { warning } from "reakit-warning"; + +import { DialogOptions } from "../Dialog"; + +import { usePortalRef } from "./usePortalRef"; + +function removeFromDOM(element: Element) { + if (element.parentNode == null) return; + element.parentNode.removeChild(element); +} + +const focusTrapClassName = "__reakit-focus-trap"; + +export function isFocusTrap(element: Element) { + return element.classList?.contains(focusTrapClassName); +} + +export function useFocusTrap( + dialogRef: React.RefObject, + visibleModals: Array>, + options: DialogOptions, +) { + const portalRef = usePortalRef(dialogRef, options); + const shouldTrap = options.visible && options.modal; + const beforeElement = React.useRef(null); + const afterElement = React.useRef(null); + + // Create before and after elements + // https://github.com/w3c/aria-practices/issues/545 + React.useEffect(() => { + if (!shouldTrap) return undefined; + const portal = portalRef.current; + + if (!portal) { + warning( + true, + "Can't trap focus within modal dialog because either `ref` wasn't passed to component or the component wasn't rendered within a portal", + "See https://reakit.io/docs/dialog", + ); + return undefined; + } + + if (!beforeElement.current) { + const document = getDocument(portal); + beforeElement.current = document.createElement("div"); + beforeElement.current.className = focusTrapClassName; + beforeElement.current.tabIndex = 0; + beforeElement.current.style.position = "fixed"; + beforeElement.current.setAttribute("aria-hidden", "true"); + } + + if (!afterElement.current) { + afterElement.current = beforeElement.current.cloneNode() as HTMLElement; + } + + portal.insertAdjacentElement("beforebegin", beforeElement.current); + portal.insertAdjacentElement("afterend", afterElement.current); + + return () => { + if (beforeElement.current) removeFromDOM(beforeElement.current); + if (afterElement.current) removeFromDOM(afterElement.current); + }; + }, [portalRef, shouldTrap]); + + // Focus trap + React.useEffect(() => { + const before = beforeElement.current; + const after = afterElement.current; + if (!shouldTrap || !before || !after) return undefined; + + const handleFocus = (event: FocusEvent) => { + const dialog = dialogRef.current; + if (!dialog || visibleModals.length) return; + + event.preventDefault(); + + const isAfter = event.target === after; + + const tabbable = isAfter + ? getFirstTabbableIn(dialog) + : getLastTabbableIn(dialog); + + if (tabbable) { + tabbable.focus(); + } else { + // fallback to dialog + dialog.focus(); + } + }; + + before.addEventListener("focus", handleFocus); + after.addEventListener("focus", handleFocus); + return () => { + before.removeEventListener("focus", handleFocus); + after.removeEventListener("focus", handleFocus); + }; + }, [dialogRef, visibleModals, shouldTrap]); +} diff --git a/src/dialog/helpers/useHideOnClickOutside.ts b/src/dialog/helpers/useHideOnClickOutside.ts new file mode 100644 index 000000000..2e5b6eef9 --- /dev/null +++ b/src/dialog/helpers/useHideOnClickOutside.ts @@ -0,0 +1,69 @@ +import * as React from "react"; +import { getDocument } from "reakit-utils/getDocument"; + +import { DialogOptions } from "../Dialog"; + +import { useEventListenerOutside } from "./useEventListenerOutside"; + +function useMouseDownRef( + dialogRef: React.RefObject, + options: DialogOptions, +) { + const mouseDownRef = React.useRef(); + + React.useEffect(() => { + if (!options.visible) return undefined; + if (!options.hideOnClickOutside) return undefined; + const document = getDocument(dialogRef.current); + const onMouseDown = (event: MouseEvent) => { + mouseDownRef.current = event.target; + }; + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [options.visible, options.hideOnClickOutside, dialogRef]); + + return mouseDownRef; +} + +export function useHideOnClickOutside( + dialogRef: React.RefObject, + disclosureRef: React.RefObject, + nestedDialogs: Array>, + options: DialogOptions, +) { + const mouseDownRef = useMouseDownRef(dialogRef, options); + + useEventListenerOutside( + dialogRef, + disclosureRef, + nestedDialogs, + "click", + event => { + // Make sure the element that has been clicked is the same that last + // triggered the mousedown event. This prevents the dialog from closing + // by dragging the cursor (for example, selecting some text inside the + // dialog and releasing the mouse outside of it). + if (mouseDownRef.current === event.target) { + options.hide?.(); + } + }, + options.visible && options.hideOnClickOutside, + ); + + useEventListenerOutside( + dialogRef, + disclosureRef, + nestedDialogs, + "focusin", + event => { + const document = getDocument(dialogRef.current); + // Fix for https://github.com/reakit/reakit/issues/619 + // On IE11, calling element.blur() triggers the focus event on + // document.body, so we make sure to ignore it as well. + if (event.target !== document && event.target !== document.body) { + options.hide?.(); + } + }, + options.visible && options.hideOnClickOutside, + ); +} diff --git a/src/dialog/helpers/useNestedDialogs.tsx b/src/dialog/helpers/useNestedDialogs.tsx new file mode 100644 index 000000000..e1a8386b5 --- /dev/null +++ b/src/dialog/helpers/useNestedDialogs.tsx @@ -0,0 +1,118 @@ +import * as React from "react"; +import { removeItemFromArray } from "reakit-utils/removeItemFromArray"; + +import { DialogOptions } from "../Dialog"; + +type DialogRef = React.RefObject; + +const DialogContext = React.createContext<{ + visible?: boolean; + addDialog?: (ref: DialogRef) => void; + removeDialog?: (ref: DialogRef) => void; + showDialog?: (ref: DialogRef) => void; + hideDialog?: (ref: DialogRef) => void; +}>({}); + +export function useNestedDialogs(dialogRef: DialogRef, options: DialogOptions) { + const context = React.useContext(DialogContext); + + const [dialogs, setDialogs] = React.useState>([]); + const [visibleModals, setVisibleModals] = React.useState(dialogs); + + const addDialog = React.useCallback( + (ref: DialogRef) => { + context.addDialog?.(ref); + setDialogs(prevDialogs => [...prevDialogs, ref]); + }, + [context.addDialog], + ); + + const removeDialog = React.useCallback( + (ref: DialogRef) => { + context.removeDialog?.(ref); + setDialogs(prevDialogs => removeItemFromArray(prevDialogs, ref)); + }, + [context.removeDialog], + ); + + const showDialog = React.useCallback( + (ref: DialogRef) => { + context.showDialog?.(ref); + setVisibleModals(prevDialogs => [...prevDialogs, ref]); + }, + [context.showDialog], + ); + + const hideDialog = React.useCallback( + (ref: DialogRef) => { + context.hideDialog?.(ref); + setVisibleModals(prevDialogs => removeItemFromArray(prevDialogs, ref)); + }, + [context.hideDialog], + ); + + // If it's a nested dialog, add it to context + React.useEffect(() => { + if (options.unstable_orphan) return undefined; + context.addDialog?.(dialogRef); + return () => { + context.removeDialog?.(dialogRef); + }; + }, [ + options.unstable_orphan, + context.addDialog, + dialogRef, + context.removeDialog, + ]); + + React.useEffect(() => { + if (options.unstable_orphan) return undefined; + if (!options.modal) return undefined; + if (!options.visible) return undefined; + context.showDialog?.(dialogRef); + return () => { + context.hideDialog?.(dialogRef); + }; + }, [ + options.unstable_orphan, + options.modal, + options.visible, + context.showDialog, + dialogRef, + context.hideDialog, + ]); + + // Close all nested dialogs when parent dialog closes + React.useEffect(() => { + if ( + context.visible === false && + options.visible && + !options.unstable_orphan + ) { + options.hide?.(); + } + }, [context.visible, options.visible, options.hide, options.unstable_orphan]); + + // Provider + const providerValue = React.useMemo( + () => ({ + visible: options.visible, + addDialog, + removeDialog, + showDialog, + hideDialog, + }), + [options.visible, addDialog, removeDialog, showDialog, hideDialog], + ); + + const wrap = React.useCallback( + (element: React.ReactNode) => ( + + {element} + + ), + [providerValue], + ); + + return { dialogs, visibleModals, wrap }; +} diff --git a/src/dialog/helpers/usePortalRef.ts b/src/dialog/helpers/usePortalRef.ts new file mode 100644 index 000000000..e2098d239 --- /dev/null +++ b/src/dialog/helpers/usePortalRef.ts @@ -0,0 +1,22 @@ +import * as React from "react"; +import { Portal } from "reakit"; +import { closest } from "reakit-utils/closest"; + +import { DialogOptions } from "../Dialog"; + +export function usePortalRef( + dialogRef: React.RefObject, + options: DialogOptions, +) { + const portalRef = React.useRef(null); + + React.useEffect(() => { + const dialog = dialogRef.current; + + if (!dialog || !options.visible) return; + + portalRef.current = closest(dialog, Portal.__selector) as HTMLElement; + }, [dialogRef, options.visible]); + + return portalRef; +} diff --git a/src/dialog/helpers/usePreventBodyScroll.ts b/src/dialog/helpers/usePreventBodyScroll.ts new file mode 100644 index 000000000..c9ece9b78 --- /dev/null +++ b/src/dialog/helpers/usePreventBodyScroll.ts @@ -0,0 +1,21 @@ +import * as React from "react"; +import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock"; + +import { DialogOptions } from "../Dialog"; + +export function usePreventBodyScroll( + targetRef: React.RefObject, + options: DialogOptions, +) { + const shouldPrevent = Boolean(options.preventBodyScroll && options.visible); + + React.useEffect(() => { + const element = targetRef.current; + + if (!element || !shouldPrevent) return undefined; + + disableBodyScroll(element, { reserveScrollBarGap: true }); + + return () => enableBodyScroll(element); + }, [targetRef, shouldPrevent]); +} diff --git a/src/dialog/index.ts b/src/dialog/index.ts new file mode 100644 index 000000000..5adb63727 --- /dev/null +++ b/src/dialog/index.ts @@ -0,0 +1,6 @@ +export * from "./__keys"; +export * from "./Dialog"; +export * from "./DialogBackdrop"; +export * from "./DialogDisclosure"; +export * from "./DialogState"; +export * from "./helpers"; diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx new file mode 100644 index 000000000..bf8368281 --- /dev/null +++ b/src/dialog/stories/DialogBasic.component.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; + +import { + Dialog, + DialogBackdrop, + DialogDisclosure, + DialogInitialState, + useDialogState, +} from "../../index"; + +export type DialogBasicProps = DialogInitialState & {}; + +export const DialogBasic: React.FC = props => { + const dialog = useDialogState(props); + const searchFieldRef = React.useRef(null); + const firstNameRef = React.useRef(null); + + return ( + <> + Open dialog + + + Welcome to Reakit! +
+ + + + + +
+
+
+
+

The search input will receive the focus after closing the dialog.

+ +
+ + ); +}; + +export default DialogBasic; diff --git a/src/dialog/stories/DialogBasic.css b/src/dialog/stories/DialogBasic.css new file mode 100644 index 000000000..1094a15d6 --- /dev/null +++ b/src/dialog/stories/DialogBasic.css @@ -0,0 +1,89 @@ +.backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.2); + perspective: 800px; +} + +.backdrop[data-enter] { + animation: fadeIn 250ms ease-in-out; +} + +.backdrop[data-leave] { + animation: fadeOut 250ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +.dialog { + position: fixed; + top: 0; + left: 0; + min-width: 300px; + min-height: 150px; + padding: 50px; + border-radius: 10px; + background-color: white; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + transform-origin: top center; +} + +.dialog:focus { + box-shadow: rgb(0 109 255 / 50%) 0px 0px 0px 0.2em; +} + +.dialog[data-enter] { + animation: slideIn 250ms ease-in-out; +} + +.dialog[data-leave] { + animation: slideOut 250ms ease-in-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translate3d(-50%, -10%, 0) rotateX(90deg); + } + + to { + opacity: 1; + transform: translate3d(-50%, -50%, 0); + } +} + +@keyframes slideOut { + from { + opacity: 1; + transform: translate3d(-50%, -50%, 0); + } + + to { + opacity: 0; + transform: translate3d(-50%, -10%, 0) rotateX(90deg); + } +} diff --git a/src/dialog/stories/DialogBasic.stories.tsx b/src/dialog/stories/DialogBasic.stories.tsx new file mode 100644 index 000000000..a05fff5b8 --- /dev/null +++ b/src/dialog/stories/DialogBasic.stories.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; +import { DialogState } from "../DialogState"; + +import css from "./templates/DialogBasicCss"; +import js from "./templates/DialogBasicJsx"; +import ts from "./templates/DialogBasicTsx"; +import { DialogBasic, DialogBasicProps } from "./DialogBasic.component"; + +import "./DialogBasic.css"; + +export default { + component: DialogBasic, + title: "Dialog/Basic", + parameters: { + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ( + +); + +export const Controlled = () => { + const [value, setValue] = React.useState(false); + console.log("%cvalue", "color: #997326", value); + + return ; +}; diff --git a/src/disclosure/DisclosureButton.tsx b/src/disclosure/Disclosure.tsx similarity index 58% rename from src/disclosure/DisclosureButton.tsx rename to src/disclosure/Disclosure.tsx index 463b92d20..11042cfb4 100644 --- a/src/disclosure/DisclosureButton.tsx +++ b/src/disclosure/Disclosure.tsx @@ -9,28 +9,26 @@ import { useLiveRef } from "reakit-utils"; import { createComposableHook } from "../system"; -import { DISCLOSURE_BUTTON_KEYS } from "./__keys"; +import { DISCLOSURE_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; -import { getState } from "./helpers"; -export type DisclosureButtonOptions = ButtonOptions & - Pick; +export type DisclosureOptions = ButtonOptions & + Pick; -export type DisclosureButtonHTMLProps = ButtonHTMLProps; +export type DisclosureHTMLProps = ButtonHTMLProps; -export type DisclosureButtonProps = DisclosureButtonOptions & - DisclosureButtonHTMLProps; +export type DisclosureProps = DisclosureOptions & DisclosureHTMLProps; export const disclosureComposableButton = createComposableHook< - DisclosureButtonOptions, - DisclosureButtonHTMLProps + DisclosureOptions, + DisclosureHTMLProps >({ - name: "DisclosureButton", + name: "Disclosure", compose: useReakitButton, - keys: DISCLOSURE_BUTTON_KEYS, + keys: DISCLOSURE_KEYS, useProps(options, htmlProps) { - const { toggle, expanded, baseId } = options; + const { toggle, visible, baseId } = options; const { onClick: htmlOnClick, "aria-controls": ariaControls, @@ -52,18 +50,19 @@ export const disclosureComposableButton = createComposableHook< return { "aria-controls": controls, - "aria-expanded": expanded, - "data-state": getState(expanded), + "aria-expanded": visible, + "data-enter": visible ? "" : undefined, + "data-leave": !visible ? "" : undefined, onClick, ...restHtmlProps, }; }, }); -export const useDisclosureButton = disclosureComposableButton(); +export const useDisclosure = disclosureComposableButton(); -export const DisclosureButton = createComponent({ +export const Disclosure = createComponent({ as: "button", memo: true, - useHook: useDisclosureButton, + useHook: useDisclosure, }); diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index 5e997681e..c92416291 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -1,4 +1,4 @@ -// Core Logic for transition is based on https://github.com/roginfarrer/react-collapsed +// Inspired from Radix UI https://github.com/radix-ui/primitives/tree/main/packages/react/collapsible import * as React from "react"; import { createComponent } from "reakit-system"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; @@ -9,11 +9,10 @@ import { createComposableHook } from "../system"; import { DISCLOSURE_CONTENT_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; -import { getState } from "./helpers"; export type DisclosureContentOptions = BoxOptions & PresenceOptions & - Pick & {}; + Pick & {}; export type DisclosureContentHTMLProps = BoxHTMLProps & PresenceHTMLProps; @@ -29,7 +28,7 @@ export const disclosureComposableContent = createComposableHook< keys: DISCLOSURE_CONTENT_KEYS, useProps(options, htmlProps) { - const { expanded, baseId, present } = options; + const { visible, baseId, present } = options; const { ref: htmlRef, style: htmlStyle, @@ -44,7 +43,7 @@ export const disclosureComposableContent = createComposableHook< const width = widthRef.current; // when opening we want it to immediately open to retrieve dimensions // when closing we delay `present` to retrieve dimensions before closing - const isExpanded = expanded || isPresent; + const isVisible = visible || isPresent; React.useLayoutEffect(() => { const node = ref.current; @@ -72,7 +71,7 @@ export const disclosureComposableContent = createComposableHook< * animation end (so when close finishes). This allows us to * retrieve the dimensions *before* closing. */ - }, [expanded, present]); + }, [visible, present]); const style = { "--content-height": height ? `${height}px` : undefined, @@ -82,11 +81,12 @@ export const disclosureComposableContent = createComposableHook< return { ref: useForkRef(ref, htmlRef), - "data-state": getState(expanded), + "data-enter": visible ? "" : undefined, + "data-leave": !visible ? "" : undefined, id: baseId, - hidden: !isExpanded, + hidden: !isVisible, style, - children: isExpanded ? htmlChildren : null, + children: isVisible ? htmlChildren : null, ...restHtmlProps, }; }, diff --git a/src/disclosure/DisclosureState.ts b/src/disclosure/DisclosureState.ts index 3028b0680..d61c147a9 100644 --- a/src/disclosure/DisclosureState.ts +++ b/src/disclosure/DisclosureState.ts @@ -14,7 +14,7 @@ export type DisclosureState = unstable_IdState & /** * Whether it's expanded or not. */ - expanded: boolean; + visible: boolean; }; export type DisclosureActions = unstable_IdActions & { @@ -36,56 +36,55 @@ export type DisclosureActions = unstable_IdActions & { /** * Sets `expanded`. */ - setExpanded: React.Dispatch< - React.SetStateAction - >; + setVisible: React.Dispatch>; }; export type DisclosureStateReturn = DisclosureState & DisclosureActions; export type DisclosureInitialState = unstable_IdInitialState & PresenceInitialState & - Partial> & { + Partial> & { /** * Default uncontrolled state. */ - defaultExpanded?: boolean; + defaultVisible?: boolean; /** * Controllabele state. */ - expanded?: boolean; + visible?: boolean; /** * controllable state callback. */ - onExpandedChange?: (expanded: boolean) => void; + onVisibleChange?: (expanded: boolean) => void; }; export const useDisclosureState = ( props: DisclosureInitialState = {}, ): DisclosureStateReturn => { const { - defaultExpanded = false, - expanded: initialExpanded, - onExpandedChange, + defaultVisible = false, + visible: initialVisible, + onVisibleChange, } = props; const id = unstable_useIdState(); - const [expanded, setExpanded] = useControllableState({ - defaultValue: defaultExpanded, - value: initialExpanded, - onChange: onExpandedChange, + const [visible, setVisible] = useControllableState({ + defaultValue: defaultVisible, + value: initialVisible, + onChange: onVisibleChange, }); - const presence = usePresenceState({ present: expanded }); + const presence = usePresenceState({ present: visible }); + console.log("%cpresence", "color: #aa00ff", presence); - const show = React.useCallback(() => setExpanded(true), [setExpanded]); - const hide = React.useCallback(() => setExpanded(false), [setExpanded]); - const toggle = React.useCallback(() => setExpanded(e => !e), [setExpanded]); + const show = React.useCallback(() => setVisible(true), [setVisible]); + const hide = React.useCallback(() => setVisible(false), [setVisible]); + const toggle = React.useCallback(() => setVisible(e => !e), [setVisible]); return { ...id, - expanded, - setExpanded, + visible, + setVisible, show, hide, toggle, diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts index edfb23ea5..e1be27b65 100644 --- a/src/disclosure/__keys.ts +++ b/src/disclosure/__keys.ts @@ -3,20 +3,19 @@ export const DISCLOSURE_STATE_KEYS = [ "baseId", "unstable_idCountRef", "isPresent", - "ref", - "expanded", + "visible", "setBaseId", "show", "hide", "toggle", - "setExpanded", + "setVisible", ] as const; export const USE_DISCLOSURE_STATE_KEYS = [ "baseId", "present", - "expanded", - "defaultExpanded", - "onExpandedChange", + "visible", + "defaultVisible", + "onVisibleChange", ] as const; -export const DISCLOSURE_BUTTON_KEYS = DISCLOSURE_STATE_KEYS; -export const DISCLOSURE_CONTENT_KEYS = DISCLOSURE_BUTTON_KEYS; +export const DISCLOSURE_KEYS = DISCLOSURE_STATE_KEYS; +export const DISCLOSURE_CONTENT_KEYS = DISCLOSURE_KEYS; diff --git a/src/disclosure/helpers.tsx b/src/disclosure/helpers.tsx deleted file mode 100644 index d5fc57b70..000000000 --- a/src/disclosure/helpers.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function getState(open?: boolean) { - return open ? "open" : "closed"; -} diff --git a/src/disclosure/index.ts b/src/disclosure/index.ts index d16604eb2..2ed1c7143 100644 --- a/src/disclosure/index.ts +++ b/src/disclosure/index.ts @@ -1,4 +1,4 @@ export * from "./__keys"; -export * from "./DisclosureButton"; +export * from "./Disclosure"; export * from "./DisclosureContent"; export * from "./DisclosureState"; diff --git a/src/disclosure/stories/DisclosureBasic.component.tsx b/src/disclosure/stories/DisclosureBasic.component.tsx index 6a3dcad1d..cb52118a7 100644 --- a/src/disclosure/stories/DisclosureBasic.component.tsx +++ b/src/disclosure/stories/DisclosureBasic.component.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { - DisclosureButton, + Disclosure, DisclosureContent, DisclosureInitialState, useDisclosureState, @@ -11,11 +11,11 @@ export type DisclosureBasicProps = DisclosureInitialState & {}; export const DisclosureBasic: React.FC = props => { const state = useDisclosureState(props); - const isOpen = state.expanded || state.isPresent; + const isOpen = state.visible || state.isPresent; return (
- Show More + Show More = args => ( ); export const Controlled = () => { - const [value, setValue] = React.useState(false); + const [value, setValue] = React.useState(false); console.log("%cvalue", "color: #997326", value); - return ; + return ; }; diff --git a/src/disclosure/stories/DisclosureHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontal.component.tsx index 91c1b04a8..1261888d9 100644 --- a/src/disclosure/stories/DisclosureHorizontal.component.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.component.tsx @@ -1,37 +1,38 @@ import * as React from "react"; import { - DisclosureButton, + Disclosure, DisclosureContent, DisclosureInitialState, useDisclosureState, } from "../../index"; -export type DisclosureProps = DisclosureInitialState & {}; +export type DisclosureHorizontalProps = DisclosureInitialState & {}; -export const Disclosure: React.FC = props => { - const state = useDisclosureState(props); - const isOpen = state.expanded || state.isPresent; +export const DisclosureHorizontal: React.FC = + props => { + const state = useDisclosureState(props); + const isOpen = state.visible || state.isPresent; - return ( -
- Show More - -
Item 1
-
Item 2
-
Item 3
-
Item 4
-
Item 5
-
Item 6
-
-
- ); -}; + return ( +
+ Show More + +
Item 1
+
Item 2
+
Item 3
+
Item 4
+
Item 5
+
Item 6
+
+
+ ); + }; -export default Disclosure; +export default DisclosureHorizontal; diff --git a/src/disclosure/stories/DisclosureHorizontal.css b/src/disclosure/stories/DisclosureHorizontal.css index 8c49750fc..ac6c2fc61 100644 --- a/src/disclosure/stories/DisclosureHorizontal.css +++ b/src/disclosure/stories/DisclosureHorizontal.css @@ -7,7 +7,7 @@ overflow: hidden; } -.content[data-state="open"] { +.content[data-enter] { animation: slideRight 300ms ease-out; } @@ -15,7 +15,7 @@ flex-shrink: 0; } -.content[data-state="closed"] { +.content[data-leave] { animation: slideLeft 300ms ease-in; } diff --git a/src/disclosure/stories/DisclosureHorizontal.stories.tsx b/src/disclosure/stories/DisclosureHorizontal.stories.tsx index 6ce8ac1d4..7c4534584 100644 --- a/src/disclosure/stories/DisclosureHorizontal.stories.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.stories.tsx @@ -7,12 +7,15 @@ import { DisclosureState } from "../DisclosureState"; import css from "./templates/DisclosureBasicCss"; import js from "./templates/DisclosureBasicJsx"; import ts from "./templates/DisclosureBasicTsx"; -import { Disclosure, DisclosureProps } from "./DisclosureHorizontal.component"; +import { + DisclosureHorizontal, + DisclosureHorizontalProps, +} from "./DisclosureHorizontal.component"; import "./DisclosureHorizontal.css"; export default { - component: Disclosure, + component: DisclosureHorizontal, title: "Disclosure/Horizontal", parameters: { layout: "centered", @@ -21,11 +24,13 @@ export default { }, } as Meta; -export const Default: Story = args => ; +export const Default: Story = args => ( + +); export const Controlled = () => { - const [value, setValue] = React.useState(false); + const [value, setValue] = React.useState(false); console.log("%cvalue", "color: #997326", value); - return ; + return ; }; diff --git a/src/index.ts b/src/index.ts index 920ae026a..7fa8b4fc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export * from "./breadcrumbs"; export * from "./calendar"; export * from "./checkbox"; export * from "./datepicker"; +export * from "./dialog"; export * from "./disclosure"; export * from "./drawer"; export * from "./link"; diff --git a/src/presence/PresenceChildren.tsx b/src/presence/PresenceChildren.tsx new file mode 100644 index 000000000..5f5aac43f --- /dev/null +++ b/src/presence/PresenceChildren.tsx @@ -0,0 +1,32 @@ +// Inspired from Radix UI Presence - https://github.com/radix-ui/primitives/tree/main/packages/react/presence +import * as React from "react"; +import { useForkRef } from "reakit-utils"; + +import { usePresenceState } from "./PresenceState"; + +export interface PresenceChildrenProps { + present: boolean; + children: + | React.ReactElement + | ((props: { present: boolean }) => React.ReactElement); +} + +export const PresenceChildren: React.FC = props => { + const { present, children } = props; + const presence = usePresenceState({ present }); + + const child = ( + typeof children === "function" + ? children({ present: presence.isPresent }) + : React.Children.only(children) + ) as React.ReactElement; + + const ref = useForkRef(presence.ref, (child as any).ref); + const forceMount = typeof children === "function"; + + return forceMount || presence.isPresent + ? React.cloneElement(child, { ref }) + : null; +}; + +PresenceChildren.displayName = "PresenceChildren"; diff --git a/src/presence/PresenceState.tsx b/src/presence/PresenceState.tsx index 8e90cf5ea..176160411 100644 --- a/src/presence/PresenceState.tsx +++ b/src/presence/PresenceState.tsx @@ -1,3 +1,4 @@ +// Inspired from Radix UI Presence - https://github.com/radix-ui/primitives/tree/main/packages/react/presence import * as React from "react"; import { useSafeLayoutEffect } from "@chakra-ui/hooks"; @@ -5,10 +6,13 @@ import { getAnimationName, useStateMachine } from "./helpers"; export type PresenceState = { isPresent: boolean; +}; + +export type PresenceActions = { ref: (node: HTMLElement) => void; }; -export type PresenceStateReturn = PresenceState; +export type PresenceStateReturn = PresenceState & PresenceActions; export type PresenceInitialState = { present?: boolean; @@ -94,6 +98,7 @@ export const usePresenceState = ( const isCurrentAnimation = currentAnimationName.includes( event.animationName, ); + if (event.target === node && isCurrentAnimation) { send("ANIMATION_END"); } @@ -104,9 +109,11 @@ export const usePresenceState = ( prevAnimationNameRef.current = getAnimationName(stylesRef.current); } }; + node.addEventListener("animationstart", handleAnimationStart); node.addEventListener("animationcancel", handleAnimationEnd); node.addEventListener("animationend", handleAnimationEnd); + return () => { node.removeEventListener("animationstart", handleAnimationStart); node.removeEventListener("animationcancel", handleAnimationEnd); diff --git a/src/presence/helpers.tsx b/src/presence/helpers.tsx index 78c1ff1c3..fafbe0456 100644 --- a/src/presence/helpers.tsx +++ b/src/presence/helpers.tsx @@ -1,5 +1,7 @@ import * as React from "react"; +// Inspired from Radix UI Presence - https://github.com/radix-ui/primitives/tree/main/packages/react/presence + type Machine = { [k: string]: { [k: string]: S } }; type MachineState = keyof T; type MachineEvent = keyof UnionToIntersection; diff --git a/src/presence/index.ts b/src/presence/index.ts index 91db5f3fd..8b18421e6 100644 --- a/src/presence/index.ts +++ b/src/presence/index.ts @@ -1,3 +1,4 @@ export * from "./__keys"; export * from "./Presence"; +export * from "./PresenceChildren"; export * from "./PresenceState"; diff --git a/yarn.lock b/yarn.lock index 59aa4349c..a05068caf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4980,6 +4980,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/body-scroll-lock@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/body-scroll-lock/-/body-scroll-lock-3.1.0.tgz#435f6abf682bf58640e1c2ee5978320b891970e7" + integrity sha512-3owAC4iJub5WPqRhxd8INarF2bWeQq1yQHBgYhN0XLBJMpd5ED10RrJ3aKiAwlTyL5wK7RkBD4SZUQz2AAAMdA== + "@types/braces@*": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/braces/-/braces-3.0.0.tgz#7da1c0d44ff1c7eb660a36ec078ea61ba7eb42cb" @@ -7045,6 +7050,11 @@ body-scroll-lock@^3.1.5: resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-3.1.5.tgz#c1392d9217ed2c3e237fee1e910f6cdd80b7aaec" integrity sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg== +body-scroll-lock@^4.0.0-beta.0: + version "4.0.0-beta.0" + resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-4.0.0-beta.0.tgz#4f78789d10e6388115c0460cd6d7d4dd2bbc4f7e" + integrity sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ== + boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" From 30b3e7999c991118692da612678f0f4d0b5f6f64 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Tue, 16 Nov 2021 18:09:14 +0530 Subject: [PATCH 04/13] =?UTF-8?q?feat(dialog):=20=E2=9C=A8=20finish=20dial?= =?UTF-8?q?og?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/preview-body.html | 1 + package.json | 5 +- src/checkbox/Checkbox.tsx | 1 - src/checkbox/helpers.tsx | 1 - src/dialog/Dialog.tsx | 76 +++++++++++-------- src/dialog/DialogBackdrop.tsx | 54 ++++++++----- src/dialog/DialogDisclosure.ts | 15 ++-- src/dialog/__keys.ts | 9 ++- src/dialog/helpers/index.ts | 1 - src/dialog/helpers/useDisclosureRef.ts | 11 ++- src/dialog/helpers/useEventListenerOutside.ts | 5 +- src/dialog/helpers/useFocusOnBlur.ts | 13 ++-- src/dialog/helpers/useFocusOnChildUnmount.ts | 4 +- src/dialog/helpers/useFocusOnHide.ts | 19 ++--- src/dialog/helpers/useFocusOnShow.ts | 12 +-- src/dialog/helpers/useFocusTrap.ts | 8 +- src/dialog/helpers/usePortalRef.ts | 5 +- src/dialog/helpers/usePreventBodyScroll.ts | 21 ----- src/dialog/stories/DialogBasic.component.tsx | 2 +- src/dialog/stories/DialogBasic.css | 18 ++--- src/disclosure/DisclosureContent.tsx | 35 +++++---- src/disclosure/DisclosureState.ts | 18 ++--- src/disclosure/__keys.ts | 8 +- .../stories/DisclosureBasic.component.tsx | 9 +-- src/disclosure/stories/DisclosureBasic.css | 1 + .../DisclosureHorizontal.component.tsx | 9 +-- .../stories/DisclosureHorizontal.css | 1 + src/link/Link.ts | 1 - yarn.lock | 53 ++++++++++++- 29 files changed, 224 insertions(+), 192 deletions(-) delete mode 100644 src/dialog/helpers/usePreventBodyScroll.ts diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index dac1e09ec..0d8aac78c 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -10,5 +10,6 @@ "Noto Color Emoji"; min-width: 100%; min-height: 100%; + -webkit-overflow-scrolling: touch; } diff --git a/package.json b/package.json index 0c4038ad1..6d842d9bf 100644 --- a/package.json +++ b/package.json @@ -94,10 +94,8 @@ "@react-aria/interactions": "^3.6.0", "@react-aria/spinbutton": "^3.0.1", "@react-aria/utils": "^3.9.0", - "@types/body-scroll-lock": "^3.1.0", - "body-scroll-lock": "^4.0.0-beta.0", "date-fns": "^2.25.0", - "raf": "^3.4.1", + "react-remove-scroll": "^2.4.3", "reakit-system": "^0.15.2", "reakit-utils": "^0.15.2", "reakit-warning": "^0.6.2" @@ -134,7 +132,6 @@ "@types/jest-in-case": "1.0.5", "@types/mockdate": "3.0.0", "@types/node": "16.11.7", - "@types/raf": "3.4.0", "@types/react": "17.0.34", "@types/react-dom": "17.0.11", "@types/react-transition-group": "4.4.4", diff --git a/src/checkbox/Checkbox.tsx b/src/checkbox/Checkbox.tsx index f4ba8d19f..d1072342e 100644 --- a/src/checkbox/Checkbox.tsx +++ b/src/checkbox/Checkbox.tsx @@ -67,7 +67,6 @@ export const useCheckbox = createHook({ warning( true, "Can't determine whether the element is a native checkbox because `ref` wasn't passed to the component", - "See https://reakit.io/docs/checkbox", ); return; } diff --git a/src/checkbox/helpers.tsx b/src/checkbox/helpers.tsx index 6d6b48d38..77b0a1ed6 100644 --- a/src/checkbox/helpers.tsx +++ b/src/checkbox/helpers.tsx @@ -43,7 +43,6 @@ export function useIndeterminateState( warning( state === "indeterminate", "Can't set indeterminate state because `ref` wasn't passed to component.", - "See https://reakit.io/docs/checkbox/#indeterminate-state", ); return; } diff --git a/src/dialog/Dialog.tsx b/src/dialog/Dialog.tsx index 84180c904..7d42c0215 100644 --- a/src/dialog/Dialog.tsx +++ b/src/dialog/Dialog.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { RemoveScroll } from "react-remove-scroll"; import { createComponent, createHook, useCreateElement } from "reakit-system"; import { Portal } from "reakit"; import { useForkRef, useLiveRef } from "reakit-utils"; @@ -8,7 +9,7 @@ import { DisclosureContentHTMLProps, DisclosureContentOptions, useDisclosureContent, -} from "../disclosure/DisclosureContent"; +} from "../disclosure"; import { DIALOG_KEYS } from "./__keys"; import { DialogStateReturn } from "./DialogState"; @@ -23,7 +24,6 @@ import { useFocusTrap, useHideOnClickOutside, useNestedDialogs, - usePreventBodyScroll, } from "./helpers"; export type DialogOptions = DisclosureContentOptions & @@ -87,16 +87,18 @@ export const useDialog = createHook({ compose: useDisclosureContent, keys: DIALOG_KEYS, - useOptions({ - modal = true, - hideOnEsc = true, - hideOnClickOutside = true, - preventBodyScroll = modal, - unstable_autoFocusOnShow = true, - unstable_autoFocusOnHide = true, - unstable_orphan, - ...options - }) { + useOptions(options) { + const { + modal = true, + hideOnEsc = true, + hideOnClickOutside = true, + preventBodyScroll = modal, + unstable_autoFocusOnShow = true, + unstable_autoFocusOnHide = true, + unstable_orphan, + ...restOptions + } = options; + return { modal, hideOnEsc, @@ -105,24 +107,29 @@ export const useDialog = createHook({ unstable_autoFocusOnShow, unstable_autoFocusOnHide, unstable_orphan: modal && unstable_orphan, - ...options, + ...restOptions, }; }, - useProps( - options, - { + useProps(options, htmlProps) { + const { + preventBodyScroll, + baseId, + hideOnEsc, + hide, + modal: optionsModal, + } = options; + const { ref: htmlRef, onKeyDown: htmlOnKeyDown, onBlur: htmlOnBlur, wrapElement: htmlWrapElement, tabIndex, - ...htmlProps - }, - ) { + ...restHtmlProps + } = htmlProps; const dialog = React.useRef(null); const backdrop = React.useContext(DialogBackdropContext); - const hasBackdrop = backdrop && backdrop === options.baseId; + const hasBackdrop = backdrop && backdrop === baseId; const disclosure = useDisclosureRef(dialog, options); const onKeyDownRef = useLiveRef(htmlOnKeyDown); const onBlurRef = useLiveRef(htmlOnBlur); @@ -131,9 +138,8 @@ export const useDialog = createHook({ // VoiceOver/Safari accepts only one `aria-modal` container, so if there // are visible child modals, then we don't want to set aria-modal on the // parent modal (this component). - const modal = options.modal && !visibleModals.length ? true : undefined; + const modal = optionsModal && !visibleModals.length ? true : undefined; - usePreventBodyScroll(dialog, options); useFocusTrap(dialog, visibleModals, options); useFocusOnChildUnmount(dialog, options); useFocusOnShow(dialog, dialogs, options); @@ -147,21 +153,20 @@ export const useDialog = createHook({ if (event.defaultPrevented) return; if (event.key !== "Escape") return; - if (!options.hideOnEsc) return; - if (!options.hide) { + if (!hideOnEsc) return; + if (!hide) { warning( true, "`hideOnEsc` prop is truthy, but `hide` prop wasn't provided.", - "See https://reakit.io/docs/dialog", dialog.current, ); return; } event.stopPropagation(); - options.hide(); + hide(); }, - [options.hideOnEsc, options.hide], + [onKeyDownRef, hideOnEsc, hide], ); const onBlur = React.useCallback( @@ -177,8 +182,16 @@ export const useDialog = createHook({ (element: React.ReactNode) => { element = wrap(element); - if (options.modal && !hasBackdrop) { - element = {element}; + if (optionsModal && !hasBackdrop) { + if (preventBodyScroll) { + element = ( + + {element} + + ); + } else { + element = {element}; + } } if (htmlWrapElement) { @@ -191,7 +204,7 @@ export const useDialog = createHook({ // ); return element; }, - [wrap, options.modal, hasBackdrop, htmlWrapElement], + [wrap, optionsModal, hasBackdrop, htmlWrapElement, preventBodyScroll], ); return { @@ -203,7 +216,7 @@ export const useDialog = createHook({ onKeyDown, onBlur, wrapElement, - ...htmlProps, + ...restHtmlProps, }; }, }); @@ -215,7 +228,6 @@ export const Dialog = createComponent({ useWarning( !props["aria-label"] && !props["aria-labelledby"], "You should provide either `aria-label` or `aria-labelledby` props.", - "See https://reakit.io/docs/dialog", ); return useCreateElement(type, props, children); }, diff --git a/src/dialog/DialogBackdrop.tsx b/src/dialog/DialogBackdrop.tsx index 80863f592..c874f6570 100644 --- a/src/dialog/DialogBackdrop.tsx +++ b/src/dialog/DialogBackdrop.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { createComponent } from "reakit-system/createComponent"; -import { createHook } from "reakit-system/createHook"; +import { RemoveScroll } from "react-remove-scroll"; +import { createComponent, createHook } from "reakit-system"; import { Portal } from "reakit"; import { @@ -14,7 +14,13 @@ import { DialogStateReturn } from "./DialogState"; import { DialogBackdropContext } from "./helpers"; export type DialogBackdropOptions = DisclosureContentOptions & - Pick, "modal">; + Pick, "modal"> & { + /** + * When enabled, user can't scroll on body when the dialog is visible. + * This option doesn't work if the dialog isn't modal. + */ + preventBodyScroll?: boolean; + }; export type DialogBackdropHTMLProps = DisclosureContentHTMLProps; @@ -29,35 +35,49 @@ export const useDialogBackdrop = createHook< compose: useDisclosureContent, keys: DIALOG_BACKDROP_KEYS, - useOptions({ modal = true, ...options }) { - return { modal, ...options }; + useOptions({ modal = true, preventBodyScroll = modal, ...options }) { + return { modal, preventBodyScroll: modal && preventBodyScroll, ...options }; }, - useProps(options, { wrapElement: htmlWrapElement, ...htmlProps }) { + useProps(options, htmlProps) { + const { modal, baseId, preventBodyScroll } = options; + const { wrapElement: htmlWrapElement, ...restHtmlProps } = htmlProps; const wrapElement = React.useCallback( (element: React.ReactNode) => { - if (options.modal) { - element = ( - - - {element} - - - ); + if (modal) { + if (preventBodyScroll) { + element = ( + + + {element} + + + ); + } else { + element = ( + + + {element} + + + ); + } } + if (htmlWrapElement) { return htmlWrapElement(element); } + return element; }, - [options.modal, options.baseId, htmlWrapElement], + [modal, htmlWrapElement, preventBodyScroll, baseId], ); return { id: undefined, - "data-dialog-ref": options.baseId, + "data-dialog-ref": baseId, wrapElement, - ...htmlProps, + ...restHtmlProps, }; }, }); diff --git a/src/dialog/DialogDisclosure.ts b/src/dialog/DialogDisclosure.ts index 2caac037f..8b882ed18 100644 --- a/src/dialog/DialogDisclosure.ts +++ b/src/dialog/DialogDisclosure.ts @@ -14,8 +14,7 @@ import { DIALOG_DISCLOSURE_KEYS } from "./__keys"; import { DialogStateReturn } from "./DialogState"; export type DialogDisclosureOptions = DisclosureOptions & - Pick, "disclosureRef"> & - Pick; + Pick, "disclosureRef">; export type DialogDisclosureHTMLProps = DisclosureHTMLProps; @@ -30,11 +29,12 @@ export const useDialogDisclosure = createHook< compose: useDisclosure, keys: DIALOG_DISCLOSURE_KEYS, - useProps(options, { ref: htmlRef, onClick: htmlOnClick, ...htmlProps }) { + useProps(options, htmlProps) { + const { disclosureRef, visible } = options; + const { ref: htmlRef, onClick: htmlOnClick, ...restHtmlProps } = htmlProps; const ref = React.useRef(null); const onClickRef = useLiveRef(htmlOnClick); const [expanded, setExpanded] = React.useState(false); - const disclosureRef = options.disclosureRef; // aria-expanded may be used for styling purposes, so we useLayoutEffect useSafeLayoutEffect(() => { @@ -43,7 +43,6 @@ export const useDialogDisclosure = createHook< warning( !element, "Can't determine whether the element is the current disclosure because `ref` wasn't passed to the component", - "See https://reakit.io/docs/dialog", ); if (disclosureRef && !disclosureRef.current) { @@ -53,8 +52,8 @@ export const useDialogDisclosure = createHook< const isCurrentDisclosure = !disclosureRef?.current || disclosureRef.current === element; - setExpanded(!!options.visible && isCurrentDisclosure); - }, [options.visible, disclosureRef]); + setExpanded(!!visible && isCurrentDisclosure); + }, [visible, disclosureRef]); const onClick = React.useCallback( (event: React.MouseEvent) => { @@ -74,7 +73,7 @@ export const useDialogDisclosure = createHook< "aria-haspopup": "dialog", "aria-expanded": expanded, onClick, - ...htmlProps, + ...restHtmlProps, }; }, }); diff --git a/src/dialog/__keys.ts b/src/dialog/__keys.ts index 40a488238..ecac0c78d 100644 --- a/src/dialog/__keys.ts +++ b/src/dialog/__keys.ts @@ -1,7 +1,6 @@ // Automatically generated export const USE_DIALOG_STATE_KEYS = [ "baseId", - "present", "visible", "defaultVisible", "onVisibleChange", @@ -10,7 +9,6 @@ export const USE_DIALOG_STATE_KEYS = [ export const DIALOG_STATE_KEYS = [ "baseId", "unstable_idCountRef", - "isPresent", "visible", "setBaseId", "show", @@ -32,5 +30,8 @@ export const DIALOG_KEYS = [ "unstable_autoFocusOnShow", "unstable_autoFocusOnHide", ] as const; -export const DIALOG_BACKDROP_KEYS = DIALOG_STATE_KEYS; -export const DIALOG_DISCLOSURE_KEYS = DIALOG_BACKDROP_KEYS; +export const DIALOG_BACKDROP_KEYS = [ + ...DIALOG_STATE_KEYS, + "preventBodyScroll", +] as const; +export const DIALOG_DISCLOSURE_KEYS = DIALOG_STATE_KEYS; diff --git a/src/dialog/helpers/index.ts b/src/dialog/helpers/index.ts index d1990d0c6..0538f4645 100644 --- a/src/dialog/helpers/index.ts +++ b/src/dialog/helpers/index.ts @@ -10,4 +10,3 @@ export * from "./useFocusTrap"; export * from "./useHideOnClickOutside"; export * from "./useNestedDialogs"; export * from "./usePortalRef"; -export * from "./usePreventBodyScroll"; diff --git a/src/dialog/helpers/useDisclosureRef.ts b/src/dialog/helpers/useDisclosureRef.ts index d84d5ea63..26f2a225f 100644 --- a/src/dialog/helpers/useDisclosureRef.ts +++ b/src/dialog/helpers/useDisclosureRef.ts @@ -1,6 +1,5 @@ import * as React from "react"; -import { getDocument } from "reakit-utils/getDocument"; -import { isButton } from "reakit-utils/isButton"; +import { getDocument, isButton } from "reakit-utils"; import { DialogOptions } from "../Dialog"; @@ -11,7 +10,7 @@ export function useDisclosureRef( const ref = React.useRef(null); React.useEffect(() => { - if (options.visible && !options.isPresent) { + if (options.visible && !options.present) { // We get the last focused element before the dialog opens, so we can move // focus back to it when the dialog closes. const onFocus = (event: FocusEvent) => { @@ -31,10 +30,10 @@ export function useDisclosureRef( return () => document.removeEventListener("focusin", onFocus); } - }, [options.visible, options.isPresent, options.disclosureRef, dialogRef]); + }, [options.visible, options.present, options.disclosureRef, dialogRef]); React.useEffect(() => { - if (!options.visible && options.isPresent) { + if (!options.visible && options.present) { // Safari and Firefox on MacOS don't focus on buttons on mouse down. // Instead, they focus on the closest focusable parent (ultimately, the // body element). This works around that by preventing that behavior and @@ -54,7 +53,7 @@ export function useDisclosureRef( return () => disclosure?.removeEventListener("mousedown", onMouseDown); } - }, [options.visible, options.isPresent, options.disclosureRef]); + }, [options.visible, options.present, options.disclosureRef]); return options.disclosureRef || ref; } diff --git a/src/dialog/helpers/useEventListenerOutside.ts b/src/dialog/helpers/useEventListenerOutside.ts index 70a9defe5..9033145cf 100644 --- a/src/dialog/helpers/useEventListenerOutside.ts +++ b/src/dialog/helpers/useEventListenerOutside.ts @@ -1,7 +1,5 @@ import * as React from "react"; -import { contains } from "reakit-utils/contains"; -import { getDocument } from "reakit-utils/getDocument"; -import { useLiveRef } from "reakit-utils/useLiveRef"; +import { contains, getDocument, useLiveRef } from "reakit-utils"; import { warning } from "reakit-warning"; import { isFocusTrap } from "./useFocusTrap"; @@ -55,7 +53,6 @@ export function useEventListenerOutside( warning( true, "Can't detect events outside dialog because `ref` wasn't passed to component.", - "See https://reakit.io/docs/dialog", ); return; } diff --git a/src/dialog/helpers/useFocusOnBlur.ts b/src/dialog/helpers/useFocusOnBlur.ts index c783d30a4..8dda64c89 100644 --- a/src/dialog/helpers/useFocusOnBlur.ts +++ b/src/dialog/helpers/useFocusOnBlur.ts @@ -1,9 +1,11 @@ import * as React from "react"; -import { getActiveElement } from "reakit-utils/getActiveElement"; -import { getDocument } from "reakit-utils/getDocument"; -import { getNextActiveElementOnBlur } from "reakit-utils/getNextActiveElementOnBlur"; -import { useIsomorphicEffect } from "reakit-utils/useIsomorphicEffect"; +import { + getActiveElement, + getDocument, + getNextActiveElementOnBlur, +} from "reakit-utils"; import { warning } from "reakit-warning"; +import { useSafeLayoutEffect } from "@chakra-ui/hooks"; import { DialogOptions } from "../Dialog"; @@ -22,7 +24,7 @@ export function useFocusOnBlur( ) { const [blurred, scheduleFocus] = React.useReducer((n: number) => n + 1, 0); - useIsomorphicEffect(() => { + useSafeLayoutEffect(() => { const dialog = dialogRef.current; if (!options.visible) return; if (!blurred) return; @@ -34,7 +36,6 @@ export function useFocusOnBlur( warning( !dialog, "Can't focus dialog after a nested element got blurred because `ref` wasn't passed to the component", - "See https://reakit.io/docs/dialog", ); dialog?.focus(); } diff --git a/src/dialog/helpers/useFocusOnChildUnmount.ts b/src/dialog/helpers/useFocusOnChildUnmount.ts index 0e8388110..d9c93d339 100644 --- a/src/dialog/helpers/useFocusOnChildUnmount.ts +++ b/src/dialog/helpers/useFocusOnChildUnmount.ts @@ -1,7 +1,5 @@ import * as React from "react"; -import { getActiveElement } from "reakit-utils/getActiveElement"; -import { getDocument } from "reakit-utils/getDocument"; -import { isEmpty } from "reakit-utils/isEmpty"; +import { getActiveElement, getDocument, isEmpty } from "reakit-utils"; import { DialogOptions } from "../Dialog"; diff --git a/src/dialog/helpers/useFocusOnHide.ts b/src/dialog/helpers/useFocusOnHide.ts index bfc3c97ec..c6942c458 100644 --- a/src/dialog/helpers/useFocusOnHide.ts +++ b/src/dialog/helpers/useFocusOnHide.ts @@ -1,10 +1,12 @@ import * as React from "react"; -import { contains } from "reakit-utils/contains"; -import { ensureFocus } from "reakit-utils/ensureFocus"; -import { getActiveElement } from "reakit-utils/getActiveElement"; -import { getDocument } from "reakit-utils/getDocument"; -import { isTabbable } from "reakit-utils/tabbable"; -import { useUpdateEffect } from "reakit-utils/useUpdateEffect"; +import { + contains, + ensureFocus, + getActiveElement, + getDocument, + isTabbable, + useUpdateEffect, +} from "reakit-utils"; import { warning } from "reakit-warning"; import { DialogOptions } from "../Dialog"; @@ -32,7 +34,7 @@ export function useFocusOnHide( useUpdateEffect(() => { if (!shouldFocus) return; - if (!options.isPresent) return; + if (!options.present) return; // Hide was triggered by a click/focus on a tabbable element outside // the dialog or on another dialog. We won't change focus then. @@ -65,8 +67,7 @@ export function useFocusOnHide( warning( true, "Can't return focus after closing dialog. Either render a disclosure component or provide a `unstable_finalFocusRef` prop.", - "See https://reakit.io/docs/dialog", dialogRef.current, ); - }, [shouldFocus, options.isPresent, dialogRef, disclosureRef]); + }, [shouldFocus, options.present, dialogRef, disclosureRef]); } diff --git a/src/dialog/helpers/useFocusOnShow.ts b/src/dialog/helpers/useFocusOnShow.ts index 596d34f6f..16295ade9 100644 --- a/src/dialog/helpers/useFocusOnShow.ts +++ b/src/dialog/helpers/useFocusOnShow.ts @@ -24,12 +24,11 @@ export function useFocusOnShow( !!shouldFocus && !dialog, "[reakit/Dialog]", "Can't set initial focus on dialog because `ref` wasn't passed to the dialog element.", - "See https://reakit.io/docs/dialog", ); if (!shouldFocus) return; if (!dialog) return; - if (!options.isPresent) return; + if (!options.present) return; // If there're nested open dialogs, let them handle focus if (nestedDialogs.some(child => child.current && !child.current.hidden)) { @@ -49,16 +48,9 @@ export function useFocusOnShow( dialog.tabIndex === undefined || dialog.tabIndex < 0, "It's recommended to have at least one tabbable element inside dialog. The dialog element has been automatically focused.", "If this is the intended behavior, pass `tabIndex={0}` to the dialog element to disable this warning.", - "See https://reakit.io/docs/dialog/#initial-focus", dialog, ); } } - }, [ - dialogRef, - shouldFocus, - options.isPresent, - nestedDialogs, - initialFocusRef, - ]); + }, [dialogRef, shouldFocus, options.present, nestedDialogs, initialFocusRef]); } diff --git a/src/dialog/helpers/useFocusTrap.ts b/src/dialog/helpers/useFocusTrap.ts index 62d18fea8..2afd528f4 100644 --- a/src/dialog/helpers/useFocusTrap.ts +++ b/src/dialog/helpers/useFocusTrap.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import { getDocument } from "reakit-utils/getDocument"; -import { getFirstTabbableIn, getLastTabbableIn } from "reakit-utils/tabbable"; +import { + getDocument, + getFirstTabbableIn, + getLastTabbableIn, +} from "reakit-utils"; import { warning } from "reakit-warning"; import { DialogOptions } from "../Dialog"; @@ -38,7 +41,6 @@ export function useFocusTrap( warning( true, "Can't trap focus within modal dialog because either `ref` wasn't passed to component or the component wasn't rendered within a portal", - "See https://reakit.io/docs/dialog", ); return undefined; } diff --git a/src/dialog/helpers/usePortalRef.ts b/src/dialog/helpers/usePortalRef.ts index e2098d239..a6d048595 100644 --- a/src/dialog/helpers/usePortalRef.ts +++ b/src/dialog/helpers/usePortalRef.ts @@ -8,15 +8,16 @@ export function usePortalRef( dialogRef: React.RefObject, options: DialogOptions, ) { + const { visible } = options; const portalRef = React.useRef(null); React.useEffect(() => { const dialog = dialogRef.current; - if (!dialog || !options.visible) return; + if (!dialog || !visible) return; portalRef.current = closest(dialog, Portal.__selector) as HTMLElement; - }, [dialogRef, options.visible]); + }, [dialogRef, visible]); return portalRef; } diff --git a/src/dialog/helpers/usePreventBodyScroll.ts b/src/dialog/helpers/usePreventBodyScroll.ts deleted file mode 100644 index c9ece9b78..000000000 --- a/src/dialog/helpers/usePreventBodyScroll.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; -import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock"; - -import { DialogOptions } from "../Dialog"; - -export function usePreventBodyScroll( - targetRef: React.RefObject, - options: DialogOptions, -) { - const shouldPrevent = Boolean(options.preventBodyScroll && options.visible); - - React.useEffect(() => { - const element = targetRef.current; - - if (!element || !shouldPrevent) return undefined; - - disableBodyScroll(element, { reserveScrollBarGap: true }); - - return () => enableBodyScroll(element); - }, [targetRef, shouldPrevent]); -} diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx index bf8368281..9e2ae8c14 100644 --- a/src/dialog/stories/DialogBasic.component.tsx +++ b/src/dialog/stories/DialogBasic.component.tsx @@ -31,7 +31,7 @@ export const DialogBasic: React.FC = props => { placeholder="Doe" ref={firstNameRef} /> - +
diff --git a/src/dialog/stories/DialogBasic.css b/src/dialog/stories/DialogBasic.css index 1094a15d6..ac12370fc 100644 --- a/src/dialog/stories/DialogBasic.css +++ b/src/dialog/stories/DialogBasic.css @@ -5,7 +5,10 @@ bottom: 0; left: 0; background-color: rgba(0, 0, 0, 0.2); - perspective: 800px; + overflow: auto; + display: flex; + align-items: flex-start; + justify-content: center; } .backdrop[data-enter] { @@ -37,19 +40,14 @@ } .dialog { - position: fixed; - top: 0; - left: 0; min-width: 300px; min-height: 150px; padding: 50px; border-radius: 10px; background-color: white; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - transform-origin: top center; + margin-top: 50px; + margin-bottom: 50px; } .dialog:focus { @@ -67,23 +65,19 @@ @keyframes slideIn { from { opacity: 0; - transform: translate3d(-50%, -10%, 0) rotateX(90deg); } to { opacity: 1; - transform: translate3d(-50%, -50%, 0); } } @keyframes slideOut { from { opacity: 1; - transform: translate3d(-50%, -50%, 0); } to { opacity: 0; - transform: translate3d(-50%, -10%, 0) rotateX(90deg); } } diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index c92416291..964db98c5 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -4,17 +4,19 @@ import { createComponent } from "reakit-system"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; import { useForkRef } from "reakit-utils"; -import { PresenceHTMLProps, PresenceOptions, usePresence } from "../presence"; +import { usePresenceState } from "../presence"; import { createComposableHook } from "../system"; import { DISCLOSURE_CONTENT_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; export type DisclosureContentOptions = BoxOptions & - PresenceOptions & - Pick & {}; + Pick & { + present: boolean; + presenceRef: ((value: any) => void) | null; + }; -export type DisclosureContentHTMLProps = BoxHTMLProps & PresenceHTMLProps; +export type DisclosureContentHTMLProps = BoxHTMLProps; export type DisclosureContentProps = DisclosureContentOptions & DisclosureContentHTMLProps; @@ -24,17 +26,22 @@ export const disclosureComposableContent = createComposableHook< DisclosureContentHTMLProps >({ name: "DisclosureContent", - compose: [useBox, usePresence], + compose: useBox, keys: DISCLOSURE_CONTENT_KEYS, + useOptions(options, htmlProps) { + const { visible } = options; + const { ref } = htmlProps; + const { isPresent: present, ref: presenceRef } = usePresenceState({ + present: visible, + }); + + return { ...options, present, presenceRef: useForkRef(ref, presenceRef) }; + }, + useProps(options, htmlProps) { - const { visible, baseId, present } = options; - const { - ref: htmlRef, - style: htmlStyle, - children: htmlChildren, - ...restHtmlProps - } = htmlProps; + const { visible, baseId, presenceRef, present } = options; + const { ref: htmlRef, style: htmlStyle, ...restHtmlProps } = htmlProps; const ref = React.useRef(null); const [isPresent, setIsPresent] = React.useState(present); const heightRef = React.useRef(0); @@ -76,17 +83,17 @@ export const disclosureComposableContent = createComposableHook< const style = { "--content-height": height ? `${height}px` : undefined, "--content-width": width ? `${width}px` : undefined, + display: isVisible ? undefined : "none", ...htmlStyle, }; return { - ref: useForkRef(ref, htmlRef), + ref: useForkRef(presenceRef, useForkRef(ref, htmlRef)), "data-enter": visible ? "" : undefined, "data-leave": !visible ? "" : undefined, id: baseId, hidden: !isVisible, style, - children: isVisible ? htmlChildren : null, ...restHtmlProps, }; }, diff --git a/src/disclosure/DisclosureState.ts b/src/disclosure/DisclosureState.ts index d61c147a9..e441c5de0 100644 --- a/src/disclosure/DisclosureState.ts +++ b/src/disclosure/DisclosureState.ts @@ -7,15 +7,13 @@ import { } from "reakit"; import { useControllableState } from "../utils"; -import { PresenceInitialState, PresenceState, usePresenceState } from ".."; -export type DisclosureState = unstable_IdState & - PresenceState & { - /** - * Whether it's expanded or not. - */ - visible: boolean; - }; +export type DisclosureState = unstable_IdState & { + /** + * Whether it's expanded or not. + */ + visible: boolean; +}; export type DisclosureActions = unstable_IdActions & { /** @@ -42,7 +40,6 @@ export type DisclosureActions = unstable_IdActions & { export type DisclosureStateReturn = DisclosureState & DisclosureActions; export type DisclosureInitialState = unstable_IdInitialState & - PresenceInitialState & Partial> & { /** * Default uncontrolled state. @@ -74,8 +71,6 @@ export const useDisclosureState = ( value: initialVisible, onChange: onVisibleChange, }); - const presence = usePresenceState({ present: visible }); - console.log("%cpresence", "color: #aa00ff", presence); const show = React.useCallback(() => setVisible(true), [setVisible]); const hide = React.useCallback(() => setVisible(false), [setVisible]); @@ -88,6 +83,5 @@ export const useDisclosureState = ( show, hide, toggle, - ...presence, }; }; diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts index e1be27b65..ca60a6507 100644 --- a/src/disclosure/__keys.ts +++ b/src/disclosure/__keys.ts @@ -2,7 +2,6 @@ export const DISCLOSURE_STATE_KEYS = [ "baseId", "unstable_idCountRef", - "isPresent", "visible", "setBaseId", "show", @@ -12,10 +11,13 @@ export const DISCLOSURE_STATE_KEYS = [ ] as const; export const USE_DISCLOSURE_STATE_KEYS = [ "baseId", - "present", "visible", "defaultVisible", "onVisibleChange", ] as const; export const DISCLOSURE_KEYS = DISCLOSURE_STATE_KEYS; -export const DISCLOSURE_CONTENT_KEYS = DISCLOSURE_KEYS; +export const DISCLOSURE_CONTENT_KEYS = [ + ...DISCLOSURE_KEYS, + "present", + "presenceRef", +] as const; diff --git a/src/disclosure/stories/DisclosureBasic.component.tsx b/src/disclosure/stories/DisclosureBasic.component.tsx index cb52118a7..84125ad55 100644 --- a/src/disclosure/stories/DisclosureBasic.component.tsx +++ b/src/disclosure/stories/DisclosureBasic.component.tsx @@ -11,18 +11,11 @@ export type DisclosureBasicProps = DisclosureInitialState & {}; export const DisclosureBasic: React.FC = props => { const state = useDisclosureState(props); - const isOpen = state.visible || state.isPresent; return (
Show More - + Item 1 Item 2 Item 3 diff --git a/src/disclosure/stories/DisclosureBasic.css b/src/disclosure/stories/DisclosureBasic.css index 30833e670..2dfb84959 100644 --- a/src/disclosure/stories/DisclosureBasic.css +++ b/src/disclosure/stories/DisclosureBasic.css @@ -1,4 +1,5 @@ .content { + display: flex; flex-direction: column; overflow: hidden; } diff --git a/src/disclosure/stories/DisclosureHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontal.component.tsx index 1261888d9..c32b686fd 100644 --- a/src/disclosure/stories/DisclosureHorizontal.component.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.component.tsx @@ -12,18 +12,11 @@ export type DisclosureHorizontalProps = DisclosureInitialState & {}; export const DisclosureHorizontal: React.FC = props => { const state = useDisclosureState(props); - const isOpen = state.visible || state.isPresent; return (
Show More - +
Item 1
Item 2
Item 3
diff --git a/src/disclosure/stories/DisclosureHorizontal.css b/src/disclosure/stories/DisclosureHorizontal.css index ac6c2fc61..6adb6c056 100644 --- a/src/disclosure/stories/DisclosureHorizontal.css +++ b/src/disclosure/stories/DisclosureHorizontal.css @@ -3,6 +3,7 @@ width: 100%; } .content { + display: flex; flex-direction: row; overflow: hidden; } diff --git a/src/link/Link.ts b/src/link/Link.ts index b654e3b16..0df15a960 100644 --- a/src/link/Link.ts +++ b/src/link/Link.ts @@ -38,7 +38,6 @@ export const useLink = createHook({ useWarning( true, "Can't determine whether the element is a native link because `ref` wasn't passed to the component", - "See https://reakit.io/docs/button", ); return; } diff --git a/yarn.lock b/yarn.lock index a05068caf..1a8e718af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9035,6 +9035,11 @@ detect-newline@3.1.0, detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + detect-port-alt@1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" @@ -10787,6 +10792,11 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -16635,6 +16645,25 @@ react-refresh@^0.8.3: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-remove-scroll-bar@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.2.0.tgz#d4d545a7df024f75d67e151499a6ab5ac97c8cdd" + integrity sha512-UU9ZBP1wdMR8qoUs7owiVcpaPwsQxUDC2lypP6mmixaGlARZa7ZIBx1jcuObLdhMOvCsnZcvetOho0wzPa9PYg== + dependencies: + react-style-singleton "^2.1.0" + tslib "^1.0.0" + +react-remove-scroll@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.4.3.tgz#83d19b02503b04bd8141ed6e0b9e6691a2e935a6" + integrity sha512-lGWYXfV6jykJwbFpsuPdexKKzp96f3RbvGapDSIdcyGvHb7/eqyn46C7/6h+rUzYar1j5mdU+XECITHXCKBk9Q== + dependencies: + react-remove-scroll-bar "^2.1.0" + react-style-singleton "^2.1.0" + tslib "^1.0.0" + use-callback-ref "^1.2.3" + use-sidecar "^1.0.1" + react-select@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/react-select/-/react-select-3.2.0.tgz#de9284700196f5f9b5277c5d850a9ce85f5c72fe" @@ -16667,6 +16696,15 @@ react-sizeme@^3.0.1: shallowequal "^1.1.0" throttle-debounce "^3.0.1" +react-style-singleton@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.1.1.tgz#ce7f90b67618be2b6b94902a30aaea152ce52e66" + integrity sha512-jNRp07Jza6CBqdRKNgGhT3u9umWvils1xsuMOjZlghBDH2MU0PL2WZor4PGYjXpnRCa9DQSlHMs/xnABWOwYbA== + dependencies: + get-nonce "^1.0.0" + invariant "^2.2.4" + tslib "^1.0.0" + react-syntax-highlighter@^13.5.3: version "13.5.3" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-13.5.3.tgz#9712850f883a3e19eb858cf93fad7bb357eea9c6" @@ -18993,7 +19031,7 @@ tsconfig-paths@^3.11.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -19443,6 +19481,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-callback-ref@^1.2.3: + version "1.2.5" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.2.5.tgz#6115ed242cfbaed5915499c0a9842ca2912f38a5" + integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg== + use-composed-ref@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.1.0.tgz#9220e4e94a97b7b02d7d27eaeab0b37034438bbc" @@ -19462,6 +19505,14 @@ use-latest@^1.0.0: dependencies: use-isomorphic-layout-effect "^1.0.0" +use-sidecar@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.0.5.tgz#ffff2a17c1df42e348624b699ba6e5c220527f2b" + integrity sha512-k9jnrjYNwN6xYLj1iaGhonDghfvmeTmYjAiGvOr7clwKfPjMXJf4/HOr7oT5tJwYafgp2tG2l3eZEOfoELiMcA== + dependencies: + detect-node-es "^1.1.0" + tslib "^1.9.3" + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 98508a07bc03a75dc7d21b8af79807b543240c5a Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Tue, 16 Nov 2021 20:13:47 +0530 Subject: [PATCH 05/13] =?UTF-8?q?feat(popover):=20=E2=9C=A8=20add=20popove?= =?UTF-8?q?r=20using=20the=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dialog/stories/DialogBasic.component.tsx | 2 +- src/index.ts | 1 + src/popover/Arrow.tsx | 42 ++ src/popover/ArrowContent.tsx | 42 ++ src/popover/Popover.tsx | 42 ++ src/popover/PopoverBackdrop.ts | 35 ++ src/popover/PopoverContent.tsx | 43 ++ src/popover/PopoverDisclosure.tsx | 48 ++ src/popover/PopoverState.ts | 169 ++++++ src/popover/__keys.ts | 73 +++ src/popover/index.ts | 7 + src/popover/popper-core.ts | 549 ++++++++++++++++++ src/popover/rect.ts | 109 ++++ .../stories/PopoverBasic.component.tsx | 53 ++ src/popover/stories/PopoverBasic.css | 43 ++ src/popover/stories/PopoverBasic.stories.tsx | 23 + .../stories/PopoverCollision.component.tsx | 169 ++++++ .../stories/PopoverCollision.stories.tsx | 19 + src/popover/useRect.ts | 27 + src/popover/useSize.ts | 60 ++ yarn.lock | 27 - 21 files changed, 1555 insertions(+), 28 deletions(-) create mode 100644 src/popover/Arrow.tsx create mode 100644 src/popover/ArrowContent.tsx create mode 100644 src/popover/Popover.tsx create mode 100644 src/popover/PopoverBackdrop.ts create mode 100644 src/popover/PopoverContent.tsx create mode 100644 src/popover/PopoverDisclosure.tsx create mode 100644 src/popover/PopoverState.ts create mode 100644 src/popover/__keys.ts create mode 100644 src/popover/index.ts create mode 100644 src/popover/popper-core.ts create mode 100644 src/popover/rect.ts create mode 100644 src/popover/stories/PopoverBasic.component.tsx create mode 100644 src/popover/stories/PopoverBasic.css create mode 100644 src/popover/stories/PopoverBasic.stories.tsx create mode 100644 src/popover/stories/PopoverCollision.component.tsx create mode 100644 src/popover/stories/PopoverCollision.stories.tsx create mode 100644 src/popover/useRect.ts create mode 100644 src/popover/useSize.ts diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx index 9e2ae8c14..6201150ea 100644 --- a/src/dialog/stories/DialogBasic.component.tsx +++ b/src/dialog/stories/DialogBasic.component.tsx @@ -11,7 +11,7 @@ import { export type DialogBasicProps = DialogInitialState & {}; export const DialogBasic: React.FC = props => { - const dialog = useDialogState(props); + const dialog = useDialogState({ modal: false }); const searchFieldRef = React.useRef(null); const firstNameRef = React.useRef(null); diff --git a/src/index.ts b/src/index.ts index 7fa8b4fc6..1a33fbdfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export * from "./meter"; export * from "./number-input"; export * from "./pagination"; export * from "./picker-base"; +export * from "./popover"; export * from "./presence"; export * from "./progress"; export * from "./radio"; diff --git a/src/popover/Arrow.tsx b/src/popover/Arrow.tsx new file mode 100644 index 000000000..fdc5d1e20 --- /dev/null +++ b/src/popover/Arrow.tsx @@ -0,0 +1,42 @@ +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; + +import { ARROW_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type ArrowOptions = RoleOptions & + Pick; + +export type ArrowHTMLProps = RoleHTMLProps; + +export type ArrowProps = ArrowOptions & ArrowHTMLProps; + +export const useArrow = createHook({ + name: "Arrow", + compose: useRole, + keys: ARROW_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { arrowStyles } = options; + const { style: htmlStyle, ...restHtmlProps } = htmlProps; + + return { + style: { + ...arrowStyles, + pointerEvents: "none", + ...htmlStyle, + }, + ...restHtmlProps, + }; + }, +}); + +export const Arrow = createComponent({ + as: "span", + memo: true, + useHook: useArrow, +}); diff --git a/src/popover/ArrowContent.tsx b/src/popover/ArrowContent.tsx new file mode 100644 index 000000000..59073dbe0 --- /dev/null +++ b/src/popover/ArrowContent.tsx @@ -0,0 +1,42 @@ +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useForkRef } from "reakit-utils"; + +import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type ArrowContentOptions = RoleOptions & + Pick; + +export type ArrowContentHTMLProps = RoleHTMLProps; + +export type ArrowContentProps = ArrowContentOptions & ArrowContentHTMLProps; + +export const useArrowContent = createHook< + ArrowContentOptions, + ArrowContentHTMLProps +>({ + name: "ArrowContent", + compose: useRole, + keys: POPOVER_DISCLOSURE_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { setArrow } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { + ref: useForkRef(setArrow, htmlRef), + ...restHtmlProps, + }; + }, +}); + +export const ArrowContent = createComponent({ + as: "div", + memo: true, + useHook: useArrowContent, +}); diff --git a/src/popover/Popover.tsx b/src/popover/Popover.tsx new file mode 100644 index 000000000..4ba9aea8a --- /dev/null +++ b/src/popover/Popover.tsx @@ -0,0 +1,42 @@ +import { createComponent, createHook } from "reakit-system"; + +import { DialogHTMLProps, DialogOptions, useDialog } from "../dialog"; + +import { POPOVER_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverOptions = DialogOptions & + Pick; + +export type PopoverHTMLProps = DialogHTMLProps; + +export type PopoverProps = PopoverOptions & PopoverHTMLProps; + +export const usePopover = createHook({ + name: "Popover", + compose: useDialog, + keys: POPOVER_KEYS, + + useOptions({ modal = false, ...options }) { + return { modal, ...options }; + }, + + useProps(options, htmlProps) { + const { popperStyles } = options; + const { style: htmlStyle, ...restHtmlProps } = htmlProps; + + return { + style: { + ...popperStyles, + ...htmlStyle, + }, + ...restHtmlProps, + }; + }, +}); + +export const Popover = createComponent({ + as: "div", + memo: true, + useHook: usePopover, +}); diff --git a/src/popover/PopoverBackdrop.ts b/src/popover/PopoverBackdrop.ts new file mode 100644 index 000000000..d72956777 --- /dev/null +++ b/src/popover/PopoverBackdrop.ts @@ -0,0 +1,35 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + DialogBackdropHTMLProps, + DialogBackdropOptions, + useDialogBackdrop, +} from "../dialog"; + +import { POPOVER_BACKDROP_KEYS } from "./__keys"; + +export type PopoverBackdropOptions = DialogBackdropOptions; + +export type PopoverBackdropHTMLProps = DialogBackdropHTMLProps; + +export type PopoverBackdropProps = PopoverBackdropOptions & + PopoverBackdropHTMLProps; + +export const usePopoverBackdrop = createHook< + PopoverBackdropOptions, + PopoverBackdropHTMLProps +>({ + name: "PopoverBackdrop", + compose: useDialogBackdrop, + keys: POPOVER_BACKDROP_KEYS, + + useOptions({ modal = false, ...options }) { + return { modal, ...options }; + }, +}); + +export const PopoverBackdrop = createComponent({ + as: "div", + memo: true, + useHook: usePopoverBackdrop, +}); diff --git a/src/popover/PopoverContent.tsx b/src/popover/PopoverContent.tsx new file mode 100644 index 000000000..19979fdbd --- /dev/null +++ b/src/popover/PopoverContent.tsx @@ -0,0 +1,43 @@ +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useForkRef } from "reakit-utils"; + +import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverContentOptions = RoleOptions & + Pick; + +export type PopoverContentHTMLProps = RoleHTMLProps; + +export type PopoverContentProps = PopoverContentOptions & + PopoverContentHTMLProps; + +export const usePopoverContent = createHook< + PopoverContentOptions, + PopoverContentHTMLProps +>({ + name: "PopoverContent", + compose: useRole, + keys: POPOVER_DISCLOSURE_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { setPopper } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { + ref: useForkRef(setPopper, htmlRef), + ...restHtmlProps, + }; + }, +}); + +export const PopoverContent = createComponent({ + as: "div", + memo: true, + useHook: usePopoverContent, +}); diff --git a/src/popover/PopoverDisclosure.tsx b/src/popover/PopoverDisclosure.tsx new file mode 100644 index 000000000..a78d21aee --- /dev/null +++ b/src/popover/PopoverDisclosure.tsx @@ -0,0 +1,48 @@ +import { createComponent, createHook } from "reakit-system"; +import { useForkRef } from "reakit-utils"; + +import { + DialogDisclosureHTMLProps, + DialogDisclosureOptions, + useDialogDisclosure, +} from "../dialog"; + +import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverDisclosureOptions = DialogDisclosureOptions & + Pick; + +export type PopoverDisclosureHTMLProps = DialogDisclosureHTMLProps; + +export type PopoverDisclosureProps = PopoverDisclosureOptions & + PopoverDisclosureHTMLProps; + +export const usePopoverDisclosure = createHook< + PopoverDisclosureOptions, + PopoverDisclosureHTMLProps +>({ + name: "PopoverDisclosure", + compose: useDialogDisclosure, + keys: POPOVER_DISCLOSURE_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { setAnchor } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { + ref: useForkRef(setAnchor, htmlRef), + ...restHtmlProps, + }; + }, +}); + +export const PopoverDisclosure = createComponent({ + as: "button", + memo: true, + useHook: usePopoverDisclosure, +}); diff --git a/src/popover/PopoverState.ts b/src/popover/PopoverState.ts new file mode 100644 index 000000000..347ee3a32 --- /dev/null +++ b/src/popover/PopoverState.ts @@ -0,0 +1,169 @@ +import * as React from "react"; + +import { + DialogActions, + DialogInitialState, + DialogState, + useDialogState, +} from ".."; + +import { + ALIGN_OPTIONS, + getPlacementData, + PlacementData, + SIDE_OPTIONS, +} from "./popper-core"; +import { useRect } from "./useRect"; +import { useSize } from "./useSize"; + +export type PopoverState = DialogState & + PlacementData & { + sideIndex: number; + alignIndex: number; + sideOffset: number; + alignOffset: number; + arrowOffset: number; + collisionTolerance: number; + anchor: HTMLDivElement | null; + popper: HTMLDivElement | null; + arrow: HTMLDivElement | null; + }; + +export type PopoverActions = DialogActions & { + setSideIndex: React.Dispatch>; + setAlignIndex: React.Dispatch>; + setSideOffset: React.Dispatch>; + setAlignOffset: React.Dispatch>; + setArrowOffset: React.Dispatch>; + setCollisionTolerance: React.Dispatch>; + setAnchor: React.Dispatch>; + setPopper: React.Dispatch>; + setArrow: React.Dispatch>; +}; + +export type PopoverStateReturn = PopoverState & PopoverActions; + +export type PopoverInitialState = DialogInitialState & + Partial< + Pick< + PopoverState, + | "sideIndex" + | "alignIndex" + | "sideOffset" + | "alignOffset" + | "arrowOffset" + | "collisionTolerance" + > + > & { + enableCollisionsDetection?: boolean; + }; + +export const usePopoverState = ( + props: PopoverInitialState = {}, +): PopoverStateReturn => { + const { + enableCollisionsDetection, + sideIndex: initialSideIndex = 1, + alignIndex: initialAlignIndex = 0, + sideOffset: initialSideOffset = 5, + alignOffset: initialAlignOffset = 0, + arrowOffset: initialArrowOffset = 20, + collisionTolerance: initialCollisionTolerance = 0, + modal = false, + ...restProps + } = props; + const dialog = useDialogState({ modal, ...restProps }); + + const [sideIndex, setSideIndex] = React.useState(initialSideIndex); + const [alignIndex, setAlignIndex] = React.useState(initialAlignIndex); + const [sideOffset, setSideOffset] = React.useState(initialSideOffset); + const [alignOffset, setAlignOffset] = React.useState(initialAlignOffset); + const [arrowOffset, setArrowOffset] = React.useState(initialArrowOffset); + const [collisionTolerance, setCollisionTolerance] = React.useState( + initialCollisionTolerance, + ); + + const side = SIDE_OPTIONS[sideIndex]; + const align = ALIGN_OPTIONS[alignIndex]; + + const [anchor, setAnchor] = React.useState(null); + const anchorRect = useRect(anchor); + + const [popper, setPopper] = React.useState(null); + const popperSize = useSize(popper); + + const [arrow, setArrow] = React.useState(null); + const arrowSize = useSize(arrow); + + const windowSize = useWindowSize(); + const collisionBoundariesRect = windowSize + ? DOMRect.fromRect({ ...windowSize, x: 0, y: 0 }) + : undefined; + + const placementData = getPlacementData({ + popperSize, + anchorRect, + arrowSize, + arrowOffset, + side, + sideOffset, + align, + alignOffset, + shouldAvoidCollisions: enableCollisionsDetection, + collisionBoundariesRect, + collisionTolerance, + }); + + return { + ...placementData, + ...dialog, + sideIndex, + alignIndex, + sideOffset, + alignOffset, + arrowOffset, + collisionTolerance, + anchor, + popper, + arrow, + setSideIndex, + setAlignIndex, + setSideOffset, + setAlignOffset, + setArrowOffset, + setCollisionTolerance, + setAnchor, + setPopper, + setArrow, + }; +}; + +const WINDOW_RESIZE_DEBOUNCE_WAIT_IN_MS = 100; + +function useWindowSize() { + const [windowSize, setWindowSize] = React.useState< + { width: number; height: number } | undefined + >(undefined); + + React.useEffect(() => { + let debounceTimerId: number; + + function updateWindowSize() { + setWindowSize({ width: window.innerWidth, height: window.innerHeight }); + } + + function handleResize() { + window.clearTimeout(debounceTimerId); + debounceTimerId = window.setTimeout( + updateWindowSize, + WINDOW_RESIZE_DEBOUNCE_WAIT_IN_MS, + ); + } + + updateWindowSize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return windowSize; +} diff --git a/src/popover/__keys.ts b/src/popover/__keys.ts new file mode 100644 index 000000000..ded0a5302 --- /dev/null +++ b/src/popover/__keys.ts @@ -0,0 +1,73 @@ +// Automatically generated +export const POPOVER_STATE_KEYS = [ + "baseId", + "unstable_idCountRef", + "visible", + "modal", + "disclosureRef", + "popperStyles", + "arrowStyles", + "placedSide", + "placedAlign", + "sideIndex", + "alignIndex", + "sideOffset", + "alignOffset", + "arrowOffset", + "collisionTolerance", + "anchor", + "popper", + "arrow", + "setBaseId", + "show", + "hide", + "toggle", + "setVisible", + "setModal", + "setSideIndex", + "setAlignIndex", + "setSideOffset", + "setAlignOffset", + "setArrowOffset", + "setCollisionTolerance", + "setAnchor", + "setPopper", + "setArrow", +] as const; +export const USE_POPOVER_STATE_KEYS = [ + "baseId", + "visible", + "defaultVisible", + "onVisibleChange", + "modal", + "sideIndex", + "alignIndex", + "sideOffset", + "alignOffset", + "arrowOffset", + "collisionTolerance", + "enableCollisionsDetection", +] as const; +export const ARROW_KEYS = POPOVER_STATE_KEYS; +export const ARROW_CONTENT_KEYS = ARROW_KEYS; +export const POPOVER_KEYS = ARROW_CONTENT_KEYS; +export const POPOVER_BACKDROP_KEYS = POPOVER_KEYS; +export const POPOVER_CONTENT_KEYS = POPOVER_BACKDROP_KEYS; +export const POPOVER_DISCLOSURE_KEYS = POPOVER_CONTENT_KEYS; +export const GET_PLACEMENT_DATA_KEYS = [ + ...POPOVER_DISCLOSURE_KEYS, + "anchorRect", + "popperSize", + "arrowSize", + "side", + "align", + "shouldAvoidCollisions", + "collisionBoundariesRect", +] as const; +export const GET_ARROW_STYLES_KEYS = [ + ...POPOVER_DISCLOSURE_KEYS, + "popperSize", + "arrowSize", + "side", + "align", +] as const; diff --git a/src/popover/index.ts b/src/popover/index.ts new file mode 100644 index 000000000..3ff430e58 --- /dev/null +++ b/src/popover/index.ts @@ -0,0 +1,7 @@ +export * from "./Arrow"; +export * from "./Popover"; +export * from "./PopoverContent"; +export * from "./PopoverContent"; +export * from "./PopoverDisclosure"; +export * from "./PopoverState"; +export * from "./popper-core"; diff --git a/src/popover/popper-core.ts b/src/popover/popper-core.ts new file mode 100644 index 000000000..901c2f7d9 --- /dev/null +++ b/src/popover/popper-core.ts @@ -0,0 +1,549 @@ +import * as CSS from "csstype"; + +const SIDE_OPTIONS = ["top", "right", "bottom", "left"] as const; +const ALIGN_OPTIONS = ["start", "center", "end"] as const; + +type Axis = "x" | "y"; +type Side = typeof SIDE_OPTIONS[number]; +type Align = typeof ALIGN_OPTIONS[number]; +type Point = { x: number; y: number }; +type Size = { width: number; height: number }; + +type GetPlacementDataOptions = { + /** The rect of the anchor we are placing around */ + anchorRect?: ClientRect; + /** The size of the popper to place */ + popperSize?: Size; + /** An optional arrow size */ + arrowSize?: Size; + /** An optional arrow offset (along the side, default: 0) */ + arrowOffset?: number; + /** The desired side */ + side: Side; + /** An optional side offset (distance from the side, default: 0) */ + sideOffset?: number; + /** The desired alignment */ + align: Align; + /** An optional alignment offset (distance along the side, default: 0) */ + alignOffset?: number; + /** An option to turn on/off the collision handling (default: true) */ + shouldAvoidCollisions?: boolean; + /** The rect which represents the boundaries for collision checks */ + collisionBoundariesRect?: ClientRect; + /** The tolerance used for collisions, ie. if we want them to trigger a bit earlier (default: 0) */ + collisionTolerance?: number; +}; + +export type PlacementData = { + popperStyles: CSS.Properties; + arrowStyles: CSS.Properties; + placedSide?: Side; + placedAlign?: Align; +}; + +/** + * Given all the information necessary to compute it, + * this function calculates all the necessary placement data. + * + * It will return: + * + * - the styles to apply to the popper (including a custom property that is useful to set the transform origin in the right place) + * - the styles to apply to the arrow + * - the placed side (because it might have changed because of collisions) + * - the placed align (because it might have changed because of collisions) + */ +function getPlacementData({ + anchorRect, + popperSize, + arrowSize, + arrowOffset = 0, + side, + sideOffset = 0, + align, + alignOffset = 0, + shouldAvoidCollisions = true, + collisionBoundariesRect, + collisionTolerance = 0, +}: GetPlacementDataOptions): PlacementData { + // if we're not ready to do all the measurements yet, + // we return some good default styles + if (!anchorRect || !popperSize || !collisionBoundariesRect) { + return { + popperStyles: UNMEASURED_POPPER_STYLES, + arrowStyles: UNMEASURED_ARROW_STYLES, + }; + } + + // pre-compute points for all potential placements + const allPlacementPoints = getAllPlacementPoints( + popperSize, + anchorRect, + sideOffset, + alignOffset, + arrowSize, + ); + + // get point based on side / align + const popperPoint = allPlacementPoints[side][align]; + + // if we don't need to avoid collisions, we can stop here + if (shouldAvoidCollisions === false) { + const popperStyles = getPlacementStylesForPoint(popperPoint); + + let arrowStyles = UNMEASURED_ARROW_STYLES; + if (arrowSize) { + arrowStyles = getPopperArrowStyles({ + popperSize, + arrowSize, + arrowOffset, + side, + align, + }); + } + + const transformOrigin = getTransformOrigin( + popperSize, + side, + align, + arrowOffset, + arrowSize, + ); + + return { + popperStyles: { + ...popperStyles, + ["--radix-popper-transform-origin" as any]: transformOrigin, + }, + arrowStyles, + placedSide: side, + placedAlign: align, + }; + } + + // create a new rect as if element had been moved to new placement + const popperRect = DOMRect.fromRect({ ...popperSize, ...popperPoint }); + + // create a new rect representing the collision boundaries but taking into account any added tolerance + const collisionBoundariesRectWithTolerance = getContractedRect( + collisionBoundariesRect, + collisionTolerance, + ); + + // check for any collisions in new placement + const popperCollisions = getCollisions( + popperRect, + collisionBoundariesRectWithTolerance, + ); + + // do all the same calculations for the opposite side + // this is because we need to check for potential collisions if we were to swap side + const oppositeSide = getOppositeSide(side); + const oppositeSidePopperPoint = allPlacementPoints[oppositeSide][align]; + const updatedOppositeSidePopperPoint = DOMRect.fromRect({ + ...popperSize, + ...oppositeSidePopperPoint, + }); + const oppositeSidePopperCollisions = getCollisions( + updatedOppositeSidePopperPoint, + collisionBoundariesRectWithTolerance, + ); + + // adjust side accounting for collisions / opposite side collisions + const placedSide = getSideAccountingForCollisions( + side, + popperCollisions, + oppositeSidePopperCollisions, + ); + + // adjust alignnment accounting for collisions + const placedAlign = getAlignAccountingForCollisions( + popperSize, + anchorRect, + side, + align, + popperCollisions, + ); + + const placedPopperPoint = allPlacementPoints[placedSide][placedAlign]; + + // compute adjusted popper / arrow styles + const popperStyles = getPlacementStylesForPoint(placedPopperPoint); + + let arrowStyles = UNMEASURED_ARROW_STYLES; + if (arrowSize) { + arrowStyles = getPopperArrowStyles({ + popperSize, + arrowSize, + arrowOffset, + side: placedSide, + align: placedAlign, + }); + } + + const transformOrigin = getTransformOrigin( + popperSize, + placedSide, + placedAlign, + arrowOffset, + arrowSize, + ); + + return { + popperStyles: { + ...popperStyles, + ["--radix-popper-transform-origin" as any]: transformOrigin, + }, + arrowStyles, + placedSide, + placedAlign, + }; +} + +type AllPlacementPoints = Record>; + +function getAllPlacementPoints( + popperSize: Size, + anchorRect: ClientRect, + sideOffset: number = 0, + alignOffset: number = 0, + arrowSize?: Size, +): AllPlacementPoints { + const arrowBaseToTipLength = arrowSize ? arrowSize.height : 0; + + const x = getPopperSlotsForAxis(anchorRect, popperSize, "x"); + const y = getPopperSlotsForAxis(anchorRect, popperSize, "y"); + + const topY = y.before - sideOffset - arrowBaseToTipLength; // prettier-ignore + const bottomY = y.after + sideOffset + arrowBaseToTipLength; // prettier-ignore + const leftX = x.before - sideOffset - arrowBaseToTipLength; // prettier-ignore + const rightX = x.after + sideOffset + arrowBaseToTipLength; // prettier-ignore + + // prettier-ignore + const map: AllPlacementPoints = { + top: { + start: { x: x.start + alignOffset, y: topY }, + center: { x: x.center, y: topY }, + end: { x: x.end - alignOffset, y: topY }, + }, + right: { + start: { x: rightX, y: y.start + alignOffset }, + center: { x: rightX, y: y.center }, + end: { x: rightX, y: y.end - alignOffset }, + }, + bottom: { + start: { x: x.start + alignOffset, y: bottomY }, + center: { x: x.center, y: bottomY }, + end: { x: x.end - alignOffset, y: bottomY }, + }, + left: { + start: { x: leftX, y: y.start + alignOffset }, + center: { x: leftX, y: y.center }, + end: { x: leftX, y: y.end - alignOffset }, + }, + }; + + return map; +} + +function getPopperSlotsForAxis( + anchorRect: ClientRect, + popperSize: Size, + axis: Axis, +) { + const startSide = axis === "x" ? "left" : "top"; + const anchorStart = anchorRect[startSide]; + + const dimension = axis === "x" ? "width" : "height"; + const anchorDimension = anchorRect[dimension]; + const popperDimension = popperSize[dimension]; + + // prettier-ignore + return { + before: anchorStart - popperDimension, + start: anchorStart, + center: anchorStart + (anchorDimension - popperDimension) / 2, + end: anchorStart + anchorDimension - popperDimension, + after: anchorStart + anchorDimension, + }; +} + +/** + * Gets an adjusted side based on collision information + */ +function getSideAccountingForCollisions( + /** The side we want to ideally position to */ + side: Side, + /** The collisions for this given side */ + collisions: Collisions, + /** The collisions for the opposite side (if we were to swap side) */ + oppositeSideCollisions: Collisions, +): Side { + const oppositeSide = getOppositeSide(side); + // in order to prevent premature jumps + // we only swap side if there's enough space to fit on the opposite side + return collisions[side] && !oppositeSideCollisions[oppositeSide] + ? oppositeSide + : side; +} + +/** + * Gets an adjusted alignment based on collision information + */ +function getAlignAccountingForCollisions( + /** The size of the popper to place */ + popperSize: Size, + /** The size of the anchor we are placing around */ + anchorSize: Size, + /** The final side */ + side: Side, + /** The desired align */ + align: Align, + /** The collisions */ + collisions: Collisions, +): Align { + const isHorizontalSide = side === "top" || side === "bottom"; + const startBound = isHorizontalSide ? "left" : "top"; + const endBound = isHorizontalSide ? "right" : "bottom"; + const dimension = isHorizontalSide ? "width" : "height"; + const isAnchorBigger = anchorSize[dimension] > popperSize[dimension]; + + if (align === "start" || align === "center") { + if ( + (collisions[startBound] && isAnchorBigger) || + (collisions[endBound] && !isAnchorBigger) + ) { + return "end"; + } + } + + if (align === "end" || align === "center") { + if ( + (collisions[endBound] && isAnchorBigger) || + (collisions[startBound] && !isAnchorBigger) + ) { + return "start"; + } + } + + return align; +} + +function getPlacementStylesForPoint(point: Point): CSS.Properties { + const x = Math.round(point.x + window.scrollX); + const y = Math.round(point.y + window.scrollY); + return { + position: "absolute", + top: 0, + left: 0, + minWidth: "max-content", + willChange: "transform", + transform: `translate3d(${x}px, ${y}px, 0)`, + }; +} + +function getTransformOrigin( + popperSize: Size, + side: Side, + align: Align, + arrowOffset: number, + arrowSize?: Size, +): CSS.Properties["transformOrigin"] { + const isHorizontalSide = side === "top" || side === "bottom"; + + const arrowBaseLength = arrowSize ? arrowSize.width : 0; + const arrowBaseToTipLength = arrowSize ? arrowSize.height : 0; + const sideOffset = arrowBaseToTipLength; + const alignOffset = arrowBaseLength / 2 + arrowOffset; + + let x = ""; + let y = ""; + + if (isHorizontalSide) { + x = { + start: `${alignOffset}px`, + center: "center", + end: `${popperSize.width - alignOffset}px`, + }[align]; + + y = + side === "top" + ? `${popperSize.height + sideOffset}px` + : `${-sideOffset}px`; + } else { + x = + side === "left" + ? `${popperSize.width + sideOffset}px` + : `${-sideOffset}px`; + + y = { + start: `${alignOffset}px`, + center: "center", + end: `${popperSize.height - alignOffset}px`, + }[align]; + } + + return `${x} ${y}`; +} + +const UNMEASURED_POPPER_STYLES: CSS.Properties = { + // position: 'fixed' here is important because it will take the popper + // out of the flow so it does not disturb the position of the anchor + position: "fixed", + top: 0, + left: 0, + opacity: 0, + transform: "translate3d(0, -200%, 0)", +}; + +const UNMEASURED_ARROW_STYLES: CSS.Properties = { + // given the arrow is nested inside the popper, + // make sure that it is out of the flow and doesn't hinder then popper's measurement + position: "absolute", + opacity: 0, +}; + +type GetArrowStylesOptions = { + /** The size of the popper to place */ + popperSize: Size; + /** The size of the arrow itself */ + arrowSize: Size; + /** An offset for the arrow along the align axis */ + arrowOffset: number; + /** The side where the arrow points to */ + side: Side; + /** The alignment of the arrow along the side */ + align: Align; +}; + +/** + * Computes the styles necessary to position, rotate and align the arrow correctly. + * It can adjust itself based on anchor/popper size, side/align and an optional offset. + */ +function getPopperArrowStyles({ + popperSize, + arrowSize, + arrowOffset, + side, + align, +}: GetArrowStylesOptions): CSS.Properties { + const popperCenterX = (popperSize.width - arrowSize.width) / 2; + const popperCenterY = (popperSize.height - arrowSize.width) / 2; + + const rotationMap = { top: 0, right: 90, bottom: 180, left: -90 }; + const rotation = rotationMap[side]; + const arrowMaxDimension = Math.max(arrowSize.width, arrowSize.height); + + const styles: CSS.Properties = { + // we make sure we put the arrow inside a 1:1 ratio container + // this is to make the rotation handling simpler + // as we do no need to worry about changing the transform-origin + width: `${arrowMaxDimension}px`, + height: `${arrowMaxDimension}px`, + + // rotate the arrow appropriately + transform: `rotate(${rotation}deg)`, + willChange: "transform", + + // position the arrow appropriately + position: "absolute", + [side]: "100%", + + // Because the arrow gets rotated (see `transform above`) + // and we are putting it inside a 1:1 ratio container + // we need to adjust the CSS direction from `ltr` to `rtl` + // in some circumstances + direction: getArrowCssDirection(side, align), + }; + + if (side === "top" || side === "bottom") { + if (align === "start") { + styles.left = `${arrowOffset}px`; + } + if (align === "center") { + styles.left = `${popperCenterX}px`; + } + if (align === "end") { + styles.right = `${arrowOffset}px`; + } + } + + if (side === "left" || side === "right") { + if (align === "start") { + styles.top = `${arrowOffset}px`; + } + if (align === "center") { + styles.top = `${popperCenterY}px`; + } + if (align === "end") { + styles.bottom = `${arrowOffset}px`; + } + } + + return styles; +} + +/** + * Adjusts the arrow's CSS direction (`ltr` / `rtl`) + */ +function getArrowCssDirection( + side: Side, + align: Align, +): CSS.Property.Direction { + if ((side === "top" || side === "right") && align === "end") { + return "rtl"; + } + + if ((side === "bottom" || side === "left") && align !== "end") { + return "rtl"; + } + + return "ltr"; +} + +/** + * Gets the opposite side of a given side (ie. top => bottom, left => right, …) + */ +function getOppositeSide(side: Side): Side { + const oppositeSides: Record = { + top: "bottom", + right: "left", + bottom: "top", + left: "right", + }; + return oppositeSides[side]; +} + +/** + * Creates a new rect (`ClientRect`) based on a given one but contracted by + * a given amout on each side. + */ +function getContractedRect(rect: ClientRect, amount: number) { + return DOMRect.fromRect({ + width: rect.width - amount * 2, + height: rect.height - amount * 2, + x: rect.left + amount, + y: rect.top + amount, + }); +} + +/** + * Gets collisions for each side of a rect (top, right, bottom, left) + */ +function getCollisions( + /** The rect to test collisions against */ + rect: ClientRect, + /** The rect which represents the boundaries for collision checks */ + collisionBoundariesRect: ClientRect, +) { + return { + top: rect.top < collisionBoundariesRect.top, + right: rect.right > collisionBoundariesRect.right, + bottom: rect.bottom > collisionBoundariesRect.bottom, + left: rect.left < collisionBoundariesRect.left, + }; +} + +type Collisions = ReturnType; + +export { ALIGN_OPTIONS, getPlacementData, SIDE_OPTIONS }; +export type { Align, Side }; diff --git a/src/popover/rect.ts b/src/popover/rect.ts new file mode 100644 index 000000000..6d40bc21f --- /dev/null +++ b/src/popover/rect.ts @@ -0,0 +1,109 @@ +type Measurable = { getBoundingClientRect(): ClientRect }; + +/** + * Observes an element's rectangle on screen (getBoundingClientRect) + * This is useful to track elements on the screen and attach other elements + * that might be in different layers, etc. + */ +function observeElementRect( + /** The element whose rect to observe */ + elementToObserve: Measurable, + /** The callback which will be called when the rect changes */ + callback: CallbackFn, +) { + const observedData = observedElements.get(elementToObserve); + + if (observedData === undefined) { + // add the element to the map of observed elements with its first callback + // because this is the first time this element is observed + observedElements.set(elementToObserve, { + rect: {} as ClientRect, + callbacks: [callback], + }); + + if (observedElements.size === 1) { + // start the internal loop once at least 1 element is observed + rafId = requestAnimationFrame(runLoop); + } + } else { + // only add a callback for this element as it's already observed + observedData.callbacks.push(callback); + callback(elementToObserve.getBoundingClientRect()); + } + + return () => { + const observedData = observedElements.get(elementToObserve); + if (observedData === undefined) return; + + // start by removing the callback + const index = observedData.callbacks.indexOf(callback); + if (index > -1) { + observedData.callbacks.splice(index, 1); + } + + if (observedData.callbacks.length === 0) { + // stop observing this element because there are no + // callbacks registered for it anymore + observedElements.delete(elementToObserve); + + if (observedElements.size === 0) { + // stop the internal loop once no elements are observed anymore + cancelAnimationFrame(rafId); + } + } + }; +} + +// ======================================================================== +// module internals + +type CallbackFn = (rect: ClientRect) => void; + +type ObservedData = { + rect: ClientRect; + callbacks: Array; +}; + +let rafId: number; +const observedElements: Map = new Map(); + +function runLoop() { + const changedRectsData: Array = []; + + // process all DOM reads first (getBoundingClientRect) + observedElements.forEach((data, element) => { + const newRect = element.getBoundingClientRect(); + + // gather all the data for elements whose rects have changed + if (!rectEquals(data.rect, newRect)) { + data.rect = newRect; + changedRectsData.push(data); + } + }); + + // group DOM writes here after the DOM reads (getBoundingClientRect) + // as DOM writes will most likely happen with the callbacks + changedRectsData.forEach(data => { + data.callbacks.forEach(callback => callback(data.rect)); + }); + + rafId = requestAnimationFrame(runLoop); +} +// ======================================================================== + +/** + * Returns whether 2 rects are equal in values + */ +function rectEquals(rect1: ClientRect, rect2: ClientRect) { + return ( + rect1.width === rect2.width && + rect1.height === rect2.height && + rect1.top === rect2.top && + rect1.right === rect2.right && + rect1.bottom === rect2.bottom && + rect1.left === rect2.left + ); +} + +export { observeElementRect }; +export type { Measurable }; diff --git a/src/popover/stories/PopoverBasic.component.tsx b/src/popover/stories/PopoverBasic.component.tsx new file mode 100644 index 000000000..706f9bc48 --- /dev/null +++ b/src/popover/stories/PopoverBasic.component.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; + +import { Arrow } from "../Arrow"; +import { ArrowContent } from "../ArrowContent"; +import { Popover } from "../Popover"; +import { PopoverContent } from "../PopoverContent"; +import { PopoverDisclosure } from "../PopoverDisclosure"; +import { PopoverInitialState, usePopoverState } from "../PopoverState"; + +export type PopoverBasicProps = PopoverInitialState & {}; + +export const PopoverBasic: React.FC = props => { + const state = usePopoverState({ + arrowOffset: 20, + sideIndex: 2, + alignIndex: 1, + }); + + return ( +
+ Open + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/src/popover/stories/PopoverBasic.css b/src/popover/stories/PopoverBasic.css new file mode 100644 index 000000000..b03948d88 --- /dev/null +++ b/src/popover/stories/PopoverBasic.css @@ -0,0 +1,43 @@ +.content { + background-color: gray; + padding: 20px; + border-radius: 5px; +} + +.arrow { + display: inline-block; + vertical-align: top; + pointer-events: auto; +} + +.popover { + transform-origin: top center; +} + +.popover[data-enter] { + animation: fadeIn 500ms ease-in-out; +} + +.popover[data-leave] { + animation: fadeOut 500ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/popover/stories/PopoverBasic.stories.tsx b/src/popover/stories/PopoverBasic.stories.tsx new file mode 100644 index 000000000..f3c0bd98c --- /dev/null +++ b/src/popover/stories/PopoverBasic.stories.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/PopoverBasicJsx"; +import ts from "./templates/PopoverBasicTsx"; +import { PopoverBasic } from "./PopoverBasic.component"; + +import "./PopoverBasic.css"; + +export default { + component: PopoverBasic, + title: "Popover/Basic", + parameters: { + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = args => ( + +); diff --git a/src/popover/stories/PopoverCollision.component.tsx b/src/popover/stories/PopoverCollision.component.tsx new file mode 100644 index 000000000..45fb62e51 --- /dev/null +++ b/src/popover/stories/PopoverCollision.component.tsx @@ -0,0 +1,169 @@ +import * as React from "react"; + +import { Arrow } from "../Arrow"; +import { ArrowContent } from "../ArrowContent"; +import { Popover } from "../Popover"; +import { PopoverContent } from "../PopoverContent"; +import { PopoverDisclosure } from "../PopoverDisclosure"; +import { usePopoverState } from "../PopoverState"; +import { ALIGN_OPTIONS, SIDE_OPTIONS } from "../popper-core"; + +export const PopoverCollision = () => { + return ( +
+ +
+ ); +}; + +const Demo = ({ disableCollisions = false }) => { + const state = usePopoverState({ + enableCollisionsDetection: !disableCollisions, + }); + const { + sideIndex, + setSideIndex, + alignIndex, + setAlignIndex, + collisionTolerance, + setCollisionTolerance, + sideOffset, + setSideOffset, + alignOffset, + setAlignOffset, + arrowOffset, + setArrowOffset, + } = state; + + return ( + <> + + Anchor + + + + +
+
+ side + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + + align + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + + arrowOffset + setArrowOffset(Number(event.target.value))} + /> +
+
+ offset + setSideOffset(Number(event.target.value))} + style={{ marginBottom: 10 }} + /> + offset + setAlignOffset(Number(event.target.value))} + style={{ marginBottom: 10 }} + /> + tolerance + + setCollisionTolerance(Number(event.target.value)) + } + /> +
+
+
+ + + + +
+ + ); +}; diff --git a/src/popover/stories/PopoverCollision.stories.tsx b/src/popover/stories/PopoverCollision.stories.tsx new file mode 100644 index 000000000..06c6defb0 --- /dev/null +++ b/src/popover/stories/PopoverCollision.stories.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/PopoverCollisionJsx"; +import ts from "./templates/PopoverCollisionTsx"; +import { PopoverCollision } from "./PopoverCollision.component"; + +export default { + component: PopoverCollision, + title: "Popover/Collision", + parameters: { + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = args => ; diff --git a/src/popover/useRect.ts b/src/popover/useRect.ts new file mode 100644 index 000000000..05b2e0d5d --- /dev/null +++ b/src/popover/useRect.ts @@ -0,0 +1,27 @@ +import * as React from "react"; + +import { Measurable, observeElementRect } from "./rect"; + +/** + * Use this custom hook to get access to an element's rect (getBoundingClientRect) + * and observe it along time. + */ +function useRect(measurable: Measurable | null) { + const [rect, setRect] = React.useState(); + + React.useEffect(() => { + if (measurable) { + const unobserve = observeElementRect(measurable, setRect); + + return () => { + setRect(undefined); + unobserve(); + }; + } + return; + }, [measurable]); + + return rect; +} + +export { useRect }; diff --git a/src/popover/useSize.ts b/src/popover/useSize.ts new file mode 100644 index 000000000..0ee346f18 --- /dev/null +++ b/src/popover/useSize.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +function useSize(element: HTMLElement | SVGElement | null) { + const [size, setSize] = React.useState< + { width: number; height: number } | undefined + >(undefined); + + React.useEffect(() => { + if (element) { + const resizeObserver = new ResizeObserver(entries => { + if (!Array.isArray(entries)) { + return; + } + + // Since we only observe the one element, we don't need to loop over the + // array + if (!entries.length) { + return; + } + + const entry = entries[0]; + let width: number; + let height: number; + + if ("borderBoxSize" in entry) { + const borderSizeEntry = entry["borderBoxSize"]; + // iron out differences between browsers + const borderSize = Array.isArray(borderSizeEntry) + ? borderSizeEntry[0] + : borderSizeEntry; + + width = borderSize["inlineSize"]; + height = borderSize["blockSize"]; + } else { + // for browsers that don't support `borderBoxSize` + // we calculate a rect ourselves to get the correct border box. + const rect = element.getBoundingClientRect(); + width = rect.width; + height = rect.height; + } + + setSize({ width, height }); + }); + + resizeObserver.observe(element, { box: "border-box" }); + + return () => { + setSize(undefined); + + resizeObserver.unobserve(element); + }; + } + + return; + }, [element]); + + return size; +} + +export { useSize }; diff --git a/yarn.lock b/yarn.lock index 1a8e718af..d00a4f7ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4980,11 +4980,6 @@ dependencies: "@babel/types" "^7.3.0" -"@types/body-scroll-lock@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@types/body-scroll-lock/-/body-scroll-lock-3.1.0.tgz#435f6abf682bf58640e1c2ee5978320b891970e7" - integrity sha512-3owAC4iJub5WPqRhxd8INarF2bWeQq1yQHBgYhN0XLBJMpd5ED10RrJ3aKiAwlTyL5wK7RkBD4SZUQz2AAAMdA== - "@types/braces@*": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/braces/-/braces-3.0.0.tgz#7da1c0d44ff1c7eb660a36ec078ea61ba7eb42cb" @@ -5288,11 +5283,6 @@ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1" integrity sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA== -"@types/raf@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2" - integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw== - "@types/reach__router@^1.3.7": version "1.3.8" resolved "https://registry.yarnpkg.com/@types/reach__router/-/reach__router-1.3.8.tgz#7b8607abf13704f918a9543257bcb7ec63028bfa" @@ -7050,11 +7040,6 @@ body-scroll-lock@^3.1.5: resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-3.1.5.tgz#c1392d9217ed2c3e237fee1e910f6cdd80b7aaec" integrity sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg== -body-scroll-lock@^4.0.0-beta.0: - version "4.0.0-beta.0" - resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-4.0.0-beta.0.tgz#4f78789d10e6388115c0460cd6d7d4dd2bbc4f7e" - integrity sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ== - boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -15722,11 +15707,6 @@ pegjs@^0.10.0: resolved "https://registry.yarnpkg.com/pegjs/-/pegjs-0.10.0.tgz#cf8bafae6eddff4b5a7efb185269eaaf4610ddbd" integrity sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0= -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= - picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -16400,13 +16380,6 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== -raf@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" - integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== - dependencies: - performance-now "^2.1.0" - ramda@^0.21.0: version "0.21.0" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35" From 89ac09c82ede1289f92e191e7148b628523f1596 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Wed, 17 Nov 2021 12:43:22 +0530 Subject: [PATCH 06/13] =?UTF-8?q?feat(popover):=20=E2=9C=A8=20finish=20pop?= =?UTF-8?q?over?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/disclosure/DisclosureContent.tsx | 1 + src/popover/Popover.tsx | 13 +++----- src/popover/PopoverContent.tsx | 13 ++++---- .../stories/PopoverBasic.component.tsx | 33 ++++++++++--------- src/popover/stories/PopoverBasic.css | 18 ++++++---- 5 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index 964db98c5..a2b3ba2f1 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -48,6 +48,7 @@ export const disclosureComposableContent = createComposableHook< const height = heightRef.current; const widthRef = React.useRef(0); const width = widthRef.current; + // when opening we want it to immediately open to retrieve dimensions // when closing we delay `present` to retrieve dimensions before closing const isVisible = visible || isPresent; diff --git a/src/popover/Popover.tsx b/src/popover/Popover.tsx index 4ba9aea8a..400e3edb7 100644 --- a/src/popover/Popover.tsx +++ b/src/popover/Popover.tsx @@ -1,26 +1,21 @@ import { createComponent, createHook } from "reakit-system"; - -import { DialogHTMLProps, DialogOptions, useDialog } from "../dialog"; +import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; import { POPOVER_KEYS } from "./__keys"; import { PopoverStateReturn } from "./PopoverState"; -export type PopoverOptions = DialogOptions & +export type PopoverOptions = BoxOptions & Pick; -export type PopoverHTMLProps = DialogHTMLProps; +export type PopoverHTMLProps = BoxHTMLProps; export type PopoverProps = PopoverOptions & PopoverHTMLProps; export const usePopover = createHook({ name: "Popover", - compose: useDialog, + compose: useBox, keys: POPOVER_KEYS, - useOptions({ modal = false, ...options }) { - return { modal, ...options }; - }, - useProps(options, htmlProps) { const { popperStyles } = options; const { style: htmlStyle, ...restHtmlProps } = htmlProps; diff --git a/src/popover/PopoverContent.tsx b/src/popover/PopoverContent.tsx index 19979fdbd..319f11a7f 100644 --- a/src/popover/PopoverContent.tsx +++ b/src/popover/PopoverContent.tsx @@ -1,14 +1,15 @@ import { createComponent, createHook } from "reakit-system"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; import { useForkRef } from "reakit-utils"; +import { DialogHTMLProps, DialogOptions, useDialog } from "../dialog"; + import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; import { PopoverStateReturn } from "./PopoverState"; -export type PopoverContentOptions = RoleOptions & +export type PopoverContentOptions = DialogOptions & Pick; -export type PopoverContentHTMLProps = RoleHTMLProps; +export type PopoverContentHTMLProps = DialogHTMLProps; export type PopoverContentProps = PopoverContentOptions & PopoverContentHTMLProps; @@ -18,11 +19,11 @@ export const usePopoverContent = createHook< PopoverContentHTMLProps >({ name: "PopoverContent", - compose: useRole, + compose: useDialog, keys: POPOVER_DISCLOSURE_KEYS, - useOptions(options, htmlProps) { - return options; + useOptions({ modal = false, ...options }) { + return { modal, ...options }; }, useProps(options, htmlProps) { diff --git a/src/popover/stories/PopoverBasic.component.tsx b/src/popover/stories/PopoverBasic.component.tsx index 706f9bc48..b6ced2643 100644 --- a/src/popover/stories/PopoverBasic.component.tsx +++ b/src/popover/stories/PopoverBasic.component.tsx @@ -29,23 +29,24 @@ export const PopoverBasic: React.FC = props => { - +
+ +
+ + + + + + +
- - - - - - - -
diff --git a/src/popover/stories/PopoverBasic.css b/src/popover/stories/PopoverBasic.css index b03948d88..c1c2052da 100644 --- a/src/popover/stories/PopoverBasic.css +++ b/src/popover/stories/PopoverBasic.css @@ -10,34 +10,38 @@ pointer-events: auto; } -.popover { +.content { transform-origin: top center; } -.popover[data-enter] { - animation: fadeIn 500ms ease-in-out; +.content[data-enter] { + animation: fadesIn 500ms ease-in-out; } -.popover[data-leave] { - animation: fadeOut 500ms ease-in-out; +.content[data-leave] { + animation: fadesOut 500ms ease-in-out; } -@keyframes fadeIn { +@keyframes fadesIn { from { opacity: 0; + transform: translate(0, -10px); } to { opacity: 1; + transform: translate(0, 0px); } } -@keyframes fadeOut { +@keyframes fadesOut { from { opacity: 1; + transform: translate(0, 0px); } to { opacity: 0; + transform: translate(0, -10px); } } From e5465b0010873b56fbb645f0113ad2eb66527bb9 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Wed, 17 Nov 2021 14:50:57 +0530 Subject: [PATCH 07/13] =?UTF-8?q?feat(popover):=20=E2=9C=A8=20add=20custom?= =?UTF-8?q?=20anchor=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/popover/Popover.tsx | 8 ++-- src/popover/PopoverAnchor.tsx | 42 +++++++++++++++++++ src/popover/PopoverState.ts | 40 +++++++----------- src/popover/PopoverTrigger.tsx | 39 +++++++++++++++++ src/popover/__keys.ts | 24 +++++------ .../stories/PopoverBasic.component.tsx | 25 +++++++---- 6 files changed, 130 insertions(+), 48 deletions(-) create mode 100644 src/popover/PopoverAnchor.tsx create mode 100644 src/popover/PopoverTrigger.tsx diff --git a/src/popover/Popover.tsx b/src/popover/Popover.tsx index 400e3edb7..b3a6cc930 100644 --- a/src/popover/Popover.tsx +++ b/src/popover/Popover.tsx @@ -1,19 +1,19 @@ import { createComponent, createHook } from "reakit-system"; -import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; import { POPOVER_KEYS } from "./__keys"; import { PopoverStateReturn } from "./PopoverState"; -export type PopoverOptions = BoxOptions & +export type PopoverOptions = RoleOptions & Pick; -export type PopoverHTMLProps = BoxHTMLProps; +export type PopoverHTMLProps = RoleHTMLProps; export type PopoverProps = PopoverOptions & PopoverHTMLProps; export const usePopover = createHook({ name: "Popover", - compose: useBox, + compose: useRole, keys: POPOVER_KEYS, useProps(options, htmlProps) { diff --git a/src/popover/PopoverAnchor.tsx b/src/popover/PopoverAnchor.tsx new file mode 100644 index 000000000..e57c13fd6 --- /dev/null +++ b/src/popover/PopoverAnchor.tsx @@ -0,0 +1,42 @@ +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useForkRef } from "reakit-utils"; + +import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverAnchorOptions = RoleOptions & + Pick; + +export type PopoverAnchorHTMLProps = RoleHTMLProps; + +export type PopoverAnchorProps = PopoverAnchorOptions & PopoverAnchorHTMLProps; + +export const usePopoverAnchor = createHook< + PopoverAnchorOptions, + PopoverAnchorHTMLProps +>({ + name: "PopoverAnchor", + compose: useRole, + keys: POPOVER_DISCLOSURE_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { setAnchor } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { + ref: useForkRef(setAnchor, htmlRef), + ...restHtmlProps, + }; + }, +}); + +export const PopoverAnchor = createComponent({ + as: "div", + memo: true, + useHook: usePopoverAnchor, +}); diff --git a/src/popover/PopoverState.ts b/src/popover/PopoverState.ts index 347ee3a32..04f82dd0c 100644 --- a/src/popover/PopoverState.ts +++ b/src/popover/PopoverState.ts @@ -5,21 +5,16 @@ import { DialogInitialState, DialogState, useDialogState, -} from ".."; +} from "../dialog"; -import { - ALIGN_OPTIONS, - getPlacementData, - PlacementData, - SIDE_OPTIONS, -} from "./popper-core"; +import { getPlacementData, PlacementData } from "./popper-core"; import { useRect } from "./useRect"; import { useSize } from "./useSize"; export type PopoverState = DialogState & PlacementData & { - sideIndex: number; - alignIndex: number; + side: "top" | "bottom" | "left" | "right"; + align: "start" | "center" | "end"; sideOffset: number; alignOffset: number; arrowOffset: number; @@ -30,8 +25,8 @@ export type PopoverState = DialogState & }; export type PopoverActions = DialogActions & { - setSideIndex: React.Dispatch>; - setAlignIndex: React.Dispatch>; + setSide: React.Dispatch>; + setAlign: React.Dispatch>; setSideOffset: React.Dispatch>; setAlignOffset: React.Dispatch>; setArrowOffset: React.Dispatch>; @@ -47,8 +42,8 @@ export type PopoverInitialState = DialogInitialState & Partial< Pick< PopoverState, - | "sideIndex" - | "alignIndex" + | "side" + | "align" | "sideOffset" | "alignOffset" | "arrowOffset" @@ -63,8 +58,8 @@ export const usePopoverState = ( ): PopoverStateReturn => { const { enableCollisionsDetection, - sideIndex: initialSideIndex = 1, - alignIndex: initialAlignIndex = 0, + side: initialSide = "bottom", + align: initialAlign = "center", sideOffset: initialSideOffset = 5, alignOffset: initialAlignOffset = 0, arrowOffset: initialArrowOffset = 20, @@ -74,8 +69,8 @@ export const usePopoverState = ( } = props; const dialog = useDialogState({ modal, ...restProps }); - const [sideIndex, setSideIndex] = React.useState(initialSideIndex); - const [alignIndex, setAlignIndex] = React.useState(initialAlignIndex); + const [side, setSide] = React.useState(initialSide); + const [align, setAlign] = React.useState(initialAlign); const [sideOffset, setSideOffset] = React.useState(initialSideOffset); const [alignOffset, setAlignOffset] = React.useState(initialAlignOffset); const [arrowOffset, setArrowOffset] = React.useState(initialArrowOffset); @@ -83,9 +78,6 @@ export const usePopoverState = ( initialCollisionTolerance, ); - const side = SIDE_OPTIONS[sideIndex]; - const align = ALIGN_OPTIONS[alignIndex]; - const [anchor, setAnchor] = React.useState(null); const anchorRect = useRect(anchor); @@ -117,8 +109,8 @@ export const usePopoverState = ( return { ...placementData, ...dialog, - sideIndex, - alignIndex, + side, + align, sideOffset, alignOffset, arrowOffset, @@ -126,8 +118,8 @@ export const usePopoverState = ( anchor, popper, arrow, - setSideIndex, - setAlignIndex, + setSide, + setAlign, setSideOffset, setAlignOffset, setArrowOffset, diff --git a/src/popover/PopoverTrigger.tsx b/src/popover/PopoverTrigger.tsx new file mode 100644 index 000000000..4a0b7092c --- /dev/null +++ b/src/popover/PopoverTrigger.tsx @@ -0,0 +1,39 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + DialogDisclosureHTMLProps, + DialogDisclosureOptions, + useDialogDisclosure, +} from "../dialog"; + +import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; + +export type PopoverTriggerOptions = DialogDisclosureOptions & {}; + +export type PopoverTriggerHTMLProps = DialogDisclosureHTMLProps; + +export type PopoverTriggerProps = PopoverTriggerOptions & + PopoverTriggerHTMLProps; + +export const usePopoverTrigger = createHook< + PopoverTriggerOptions, + PopoverTriggerHTMLProps +>({ + name: "PopoverTrigger", + compose: useDialogDisclosure, + keys: POPOVER_DISCLOSURE_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const PopoverTrigger = createComponent({ + as: "button", + memo: true, + useHook: usePopoverTrigger, +}); diff --git a/src/popover/__keys.ts b/src/popover/__keys.ts index ded0a5302..03999195d 100644 --- a/src/popover/__keys.ts +++ b/src/popover/__keys.ts @@ -9,8 +9,8 @@ export const POPOVER_STATE_KEYS = [ "arrowStyles", "placedSide", "placedAlign", - "sideIndex", - "alignIndex", + "side", + "align", "sideOffset", "alignOffset", "arrowOffset", @@ -24,8 +24,8 @@ export const POPOVER_STATE_KEYS = [ "toggle", "setVisible", "setModal", - "setSideIndex", - "setAlignIndex", + "setSide", + "setAlign", "setSideOffset", "setAlignOffset", "setArrowOffset", @@ -40,8 +40,8 @@ export const USE_POPOVER_STATE_KEYS = [ "defaultVisible", "onVisibleChange", "modal", - "sideIndex", - "alignIndex", + "side", + "align", "sideOffset", "alignOffset", "arrowOffset", @@ -51,23 +51,21 @@ export const USE_POPOVER_STATE_KEYS = [ export const ARROW_KEYS = POPOVER_STATE_KEYS; export const ARROW_CONTENT_KEYS = ARROW_KEYS; export const POPOVER_KEYS = ARROW_CONTENT_KEYS; -export const POPOVER_BACKDROP_KEYS = POPOVER_KEYS; +export const POPOVER_ANCHOR_KEYS = POPOVER_KEYS; +export const POPOVER_BACKDROP_KEYS = POPOVER_ANCHOR_KEYS; export const POPOVER_CONTENT_KEYS = POPOVER_BACKDROP_KEYS; export const POPOVER_DISCLOSURE_KEYS = POPOVER_CONTENT_KEYS; +export const POPOVER_TRIGGER_KEYS = POPOVER_DISCLOSURE_KEYS; export const GET_PLACEMENT_DATA_KEYS = [ - ...POPOVER_DISCLOSURE_KEYS, + ...POPOVER_TRIGGER_KEYS, "anchorRect", "popperSize", "arrowSize", - "side", - "align", "shouldAvoidCollisions", "collisionBoundariesRect", ] as const; export const GET_ARROW_STYLES_KEYS = [ - ...POPOVER_DISCLOSURE_KEYS, + ...POPOVER_TRIGGER_KEYS, "popperSize", "arrowSize", - "side", - "align", ] as const; diff --git a/src/popover/stories/PopoverBasic.component.tsx b/src/popover/stories/PopoverBasic.component.tsx index b6ced2643..2179f37ff 100644 --- a/src/popover/stories/PopoverBasic.component.tsx +++ b/src/popover/stories/PopoverBasic.component.tsx @@ -3,18 +3,15 @@ import * as React from "react"; import { Arrow } from "../Arrow"; import { ArrowContent } from "../ArrowContent"; import { Popover } from "../Popover"; +import { PopoverAnchor } from "../PopoverAnchor"; import { PopoverContent } from "../PopoverContent"; -import { PopoverDisclosure } from "../PopoverDisclosure"; import { PopoverInitialState, usePopoverState } from "../PopoverState"; +import { PopoverTrigger } from "../PopoverTrigger"; export type PopoverBasicProps = PopoverInitialState & {}; export const PopoverBasic: React.FC = props => { - const state = usePopoverState({ - arrowOffset: 20, - sideIndex: 2, - alignIndex: 1, - }); + const state = usePopoverState(props); return (
= props => { height: "200vh", }} > - Open + + Item + Open + From 990a16e95b06ae9a1aaf93e934a14047b528669e Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Wed, 17 Nov 2021 19:31:35 +0530 Subject: [PATCH 08/13] =?UTF-8?q?feat(tooltip):=20=E2=9C=A8=20add=20toolti?= =?UTF-8?q?p=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/popover/PopoverAnchor.tsx | 4 +- src/popover/{Arrow.tsx => PopoverArrow.tsx} | 21 +- ...rowContent.tsx => PopoverArrowContent.tsx} | 23 +- src/popover/PopoverTrigger.tsx | 4 +- src/popover/__keys.ts | 8 +- src/popover/index.ts | 6 +- .../stories/PopoverBasic.component.tsx | 12 +- .../stories/PopoverCollision.component.tsx | 40 ++- src/tooltip/README.md | 334 ++++++++++++++++++ src/tooltip/Tooltip.tsx | 70 ++++ src/tooltip/TooltipAnchor.tsx | 40 +++ src/tooltip/TooltipArrow.ts | 39 ++ src/tooltip/TooltipArrowContent.ts | 39 ++ src/tooltip/TooltipContent.tsx | 65 ++++ src/tooltip/TooltipReference.ts | 94 +++++ src/tooltip/TooltipState.ts | 116 ++++++ src/tooltip/TooltipTrigger.ts | 93 +++++ src/tooltip/__globalState.ts | 24 ++ src/tooltip/__keys.ts | 57 +++ src/tooltip/index.ts | 8 + .../stories/TooltipBasic.component.tsx | 34 ++ src/tooltip/stories/TooltipBasic.css | 38 ++ src/tooltip/stories/TooltipBasic.stories.tsx | 27 ++ .../stories/TooltipCustomAnchor.component.tsx | 56 +++ src/tooltip/stories/TooltipCustomAnchor.css | 47 +++ .../stories/TooltipCustomAnchor.stories.tsx | 25 ++ .../TooltipCustomContent.component.tsx | 141 ++++++++ src/tooltip/stories/TooltipCustomContent.css | 45 +++ .../stories/TooltipCustomContent.stories.tsx | 23 ++ .../TooltipCustomDuration.component.tsx | 73 ++++ src/tooltip/stories/TooltipCustomDuration.css | 45 +++ .../stories/TooltipCustomDuration.stories.tsx | 23 ++ .../stories/TooltipPositions.component.tsx | 156 ++++++++ src/tooltip/stories/TooltipPositions.css | 44 +++ .../stories/TooltipPositions.stories.tsx | 23 ++ 35 files changed, 1843 insertions(+), 54 deletions(-) rename src/popover/{Arrow.tsx => PopoverArrow.tsx} (58%) rename src/popover/{ArrowContent.tsx => PopoverArrowContent.tsx} (53%) create mode 100644 src/tooltip/README.md create mode 100644 src/tooltip/Tooltip.tsx create mode 100644 src/tooltip/TooltipAnchor.tsx create mode 100644 src/tooltip/TooltipArrow.ts create mode 100644 src/tooltip/TooltipArrowContent.ts create mode 100644 src/tooltip/TooltipContent.tsx create mode 100644 src/tooltip/TooltipReference.ts create mode 100644 src/tooltip/TooltipState.ts create mode 100644 src/tooltip/TooltipTrigger.ts create mode 100644 src/tooltip/__globalState.ts create mode 100644 src/tooltip/__keys.ts create mode 100644 src/tooltip/index.ts create mode 100644 src/tooltip/stories/TooltipBasic.component.tsx create mode 100644 src/tooltip/stories/TooltipBasic.css create mode 100644 src/tooltip/stories/TooltipBasic.stories.tsx create mode 100644 src/tooltip/stories/TooltipCustomAnchor.component.tsx create mode 100644 src/tooltip/stories/TooltipCustomAnchor.css create mode 100644 src/tooltip/stories/TooltipCustomAnchor.stories.tsx create mode 100644 src/tooltip/stories/TooltipCustomContent.component.tsx create mode 100644 src/tooltip/stories/TooltipCustomContent.css create mode 100644 src/tooltip/stories/TooltipCustomContent.stories.tsx create mode 100644 src/tooltip/stories/TooltipCustomDuration.component.tsx create mode 100644 src/tooltip/stories/TooltipCustomDuration.css create mode 100644 src/tooltip/stories/TooltipCustomDuration.stories.tsx create mode 100644 src/tooltip/stories/TooltipPositions.component.tsx create mode 100644 src/tooltip/stories/TooltipPositions.css create mode 100644 src/tooltip/stories/TooltipPositions.stories.tsx diff --git a/src/popover/PopoverAnchor.tsx b/src/popover/PopoverAnchor.tsx index e57c13fd6..32287e6d6 100644 --- a/src/popover/PopoverAnchor.tsx +++ b/src/popover/PopoverAnchor.tsx @@ -2,7 +2,7 @@ import { createComponent, createHook } from "reakit-system"; import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; import { useForkRef } from "reakit-utils"; -import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { POPOVER_ANCHOR_KEYS } from "./__keys"; import { PopoverStateReturn } from "./PopoverState"; export type PopoverAnchorOptions = RoleOptions & @@ -18,7 +18,7 @@ export const usePopoverAnchor = createHook< >({ name: "PopoverAnchor", compose: useRole, - keys: POPOVER_DISCLOSURE_KEYS, + keys: POPOVER_ANCHOR_KEYS, useOptions(options, htmlProps) { return options; diff --git a/src/popover/Arrow.tsx b/src/popover/PopoverArrow.tsx similarity index 58% rename from src/popover/Arrow.tsx rename to src/popover/PopoverArrow.tsx index fdc5d1e20..ada690c66 100644 --- a/src/popover/Arrow.tsx +++ b/src/popover/PopoverArrow.tsx @@ -1,20 +1,23 @@ import { createComponent, createHook } from "reakit-system"; import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { ARROW_KEYS } from "./__keys"; +import { POPOVER_ARROW_KEYS } from "./__keys"; import { PopoverStateReturn } from "./PopoverState"; -export type ArrowOptions = RoleOptions & +export type PopoverArrowOptions = RoleOptions & Pick; -export type ArrowHTMLProps = RoleHTMLProps; +export type PopoverArrowHTMLProps = RoleHTMLProps; -export type ArrowProps = ArrowOptions & ArrowHTMLProps; +export type PopoverArrowProps = PopoverArrowOptions & PopoverArrowHTMLProps; -export const useArrow = createHook({ - name: "Arrow", +export const usePopoverArrow = createHook< + PopoverArrowOptions, + PopoverArrowHTMLProps +>({ + name: "PopoverArrow", compose: useRole, - keys: ARROW_KEYS, + keys: POPOVER_ARROW_KEYS, useOptions(options, htmlProps) { return options; @@ -35,8 +38,8 @@ export const useArrow = createHook({ }, }); -export const Arrow = createComponent({ +export const PopoverArrow = createComponent({ as: "span", memo: true, - useHook: useArrow, + useHook: usePopoverArrow, }); diff --git a/src/popover/ArrowContent.tsx b/src/popover/PopoverArrowContent.tsx similarity index 53% rename from src/popover/ArrowContent.tsx rename to src/popover/PopoverArrowContent.tsx index 59073dbe0..31efe244a 100644 --- a/src/popover/ArrowContent.tsx +++ b/src/popover/PopoverArrowContent.tsx @@ -2,23 +2,24 @@ import { createComponent, createHook } from "reakit-system"; import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; import { useForkRef } from "reakit-utils"; -import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { POPOVER_ARROW_CONTENT_KEYS } from "./__keys"; import { PopoverStateReturn } from "./PopoverState"; -export type ArrowContentOptions = RoleOptions & +export type PopoverArrowContentOptions = RoleOptions & Pick; -export type ArrowContentHTMLProps = RoleHTMLProps; +export type PopoverArrowContentHTMLProps = RoleHTMLProps; -export type ArrowContentProps = ArrowContentOptions & ArrowContentHTMLProps; +export type PopoverArrowContentProps = PopoverArrowContentOptions & + PopoverArrowContentHTMLProps; -export const useArrowContent = createHook< - ArrowContentOptions, - ArrowContentHTMLProps +export const usePopoverArrowContent = createHook< + PopoverArrowContentOptions, + PopoverArrowContentHTMLProps >({ - name: "ArrowContent", + name: "PopoverArrowContent", compose: useRole, - keys: POPOVER_DISCLOSURE_KEYS, + keys: POPOVER_ARROW_CONTENT_KEYS, useOptions(options, htmlProps) { return options; @@ -35,8 +36,8 @@ export const useArrowContent = createHook< }, }); -export const ArrowContent = createComponent({ +export const PopoverArrowContent = createComponent({ as: "div", memo: true, - useHook: useArrowContent, + useHook: usePopoverArrowContent, }); diff --git a/src/popover/PopoverTrigger.tsx b/src/popover/PopoverTrigger.tsx index 4a0b7092c..ed5e9e13f 100644 --- a/src/popover/PopoverTrigger.tsx +++ b/src/popover/PopoverTrigger.tsx @@ -6,7 +6,7 @@ import { useDialogDisclosure, } from "../dialog"; -import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { POPOVER_TRIGGER_KEYS } from "./__keys"; export type PopoverTriggerOptions = DialogDisclosureOptions & {}; @@ -21,7 +21,7 @@ export const usePopoverTrigger = createHook< >({ name: "PopoverTrigger", compose: useDialogDisclosure, - keys: POPOVER_DISCLOSURE_KEYS, + keys: POPOVER_TRIGGER_KEYS, useOptions(options, htmlProps) { return options; diff --git a/src/popover/__keys.ts b/src/popover/__keys.ts index 03999195d..aea2dd024 100644 --- a/src/popover/__keys.ts +++ b/src/popover/__keys.ts @@ -48,11 +48,11 @@ export const USE_POPOVER_STATE_KEYS = [ "collisionTolerance", "enableCollisionsDetection", ] as const; -export const ARROW_KEYS = POPOVER_STATE_KEYS; -export const ARROW_CONTENT_KEYS = ARROW_KEYS; -export const POPOVER_KEYS = ARROW_CONTENT_KEYS; +export const POPOVER_KEYS = POPOVER_STATE_KEYS; export const POPOVER_ANCHOR_KEYS = POPOVER_KEYS; -export const POPOVER_BACKDROP_KEYS = POPOVER_ANCHOR_KEYS; +export const POPOVER_ARROW_KEYS = POPOVER_ANCHOR_KEYS; +export const POPOVER_ARROW_CONTENT_KEYS = POPOVER_ARROW_KEYS; +export const POPOVER_BACKDROP_KEYS = POPOVER_ARROW_CONTENT_KEYS; export const POPOVER_CONTENT_KEYS = POPOVER_BACKDROP_KEYS; export const POPOVER_DISCLOSURE_KEYS = POPOVER_CONTENT_KEYS; export const POPOVER_TRIGGER_KEYS = POPOVER_DISCLOSURE_KEYS; diff --git a/src/popover/index.ts b/src/popover/index.ts index 3ff430e58..1e32c2acb 100644 --- a/src/popover/index.ts +++ b/src/popover/index.ts @@ -1,7 +1,9 @@ -export * from "./Arrow"; export * from "./Popover"; -export * from "./PopoverContent"; +export * from "./PopoverAnchor"; +export * from "./PopoverArrow"; +export * from "./PopoverArrowContent"; export * from "./PopoverContent"; export * from "./PopoverDisclosure"; export * from "./PopoverState"; +export * from "./PopoverTrigger"; export * from "./popper-core"; diff --git a/src/popover/stories/PopoverBasic.component.tsx b/src/popover/stories/PopoverBasic.component.tsx index 2179f37ff..fc1956a62 100644 --- a/src/popover/stories/PopoverBasic.component.tsx +++ b/src/popover/stories/PopoverBasic.component.tsx @@ -1,9 +1,9 @@ import * as React from "react"; -import { Arrow } from "../Arrow"; -import { ArrowContent } from "../ArrowContent"; import { Popover } from "../Popover"; import { PopoverAnchor } from "../PopoverAnchor"; +import { PopoverArrow } from "../PopoverArrow"; +import { PopoverArrowContent } from "../PopoverArrowContent"; import { PopoverContent } from "../PopoverContent"; import { PopoverInitialState, usePopoverState } from "../PopoverState"; import { PopoverTrigger } from "../PopoverTrigger"; @@ -43,8 +43,8 @@ export const PopoverBasic: React.FC = props => {
- - + + = props => { > - - + +
diff --git a/src/popover/stories/PopoverCollision.component.tsx b/src/popover/stories/PopoverCollision.component.tsx index 45fb62e51..d6c1f81da 100644 --- a/src/popover/stories/PopoverCollision.component.tsx +++ b/src/popover/stories/PopoverCollision.component.tsx @@ -1,11 +1,11 @@ import * as React from "react"; -import { Arrow } from "../Arrow"; -import { ArrowContent } from "../ArrowContent"; import { Popover } from "../Popover"; +import { PopoverArrow } from "../PopoverArrow"; +import { PopoverArrowContent } from "../PopoverArrowContent"; import { PopoverContent } from "../PopoverContent"; import { PopoverDisclosure } from "../PopoverDisclosure"; -import { usePopoverState } from "../PopoverState"; +import { PopoverState, usePopoverState } from "../PopoverState"; import { ALIGN_OPTIONS, SIDE_OPTIONS } from "../popper-core"; export const PopoverCollision = () => { @@ -29,10 +29,10 @@ const Demo = ({ disableCollisions = false }) => { enableCollisionsDetection: !disableCollisions, }); const { - sideIndex, - setSideIndex, - alignIndex, - setAlignIndex, + side, + setSide, + align, + setAlign, collisionTolerance, setCollisionTolerance, sideOffset, @@ -82,12 +82,14 @@ const Demo = ({ disableCollisions = false }) => { side {/* eslint-disable-next-line jsx-a11y/no-onchange */} setAlignIndex(Number(event.target.value))} + value={align} + onChange={event => + setAlign(event.target.value as PopoverState["align"]) + } style={{ marginBottom: 10 }} > - {ALIGN_OPTIONS.map((align, index) => ( - ))} @@ -151,8 +155,8 @@ const Demo = ({ disableCollisions = false }) => {
- - + { backgroundColor: "hotpink", }} /> - + ); diff --git a/src/tooltip/README.md b/src/tooltip/README.md new file mode 100644 index 000000000..5608e19ae --- /dev/null +++ b/src/tooltip/README.md @@ -0,0 +1,334 @@ +--- +path: /docs/tooltip/ +redirect_from: + - /components/tooltip/ + - /components/tooltip/tooltiparrow/ +--- + +# Tooltip + +`Tooltip` follows the +[WAI-ARIA Tooltip Pattern](https://www.w3.org/TR/wai-aria-practices/#tooltip). +It's a popup that displays information related to an element when the element +receives keyboard focus or the mouse hovers over it. + + + +## Installation + +```sh +npm install reakit +``` + +Learn more in [Get started](/docs/get-started/). + +## Usage + +```jsx +import { Button } from "reakit/Button"; +import { Tooltip, TooltipReference, useTooltipState } from "reakit/Tooltip"; + +function Example() { + const tooltip = useTooltipState(); + return ( + <> + + Reference + + Tooltip + + ); +} +``` + +### Placement + +Since `Tooltip` is composed by [Popover](/docs/popover/), you can control how it +is positioned by setting the `placement` option on `useTooltipState`. + +```jsx +import { Button } from "reakit/Button"; +import { Tooltip, TooltipReference, useTooltipState } from "reakit/Tooltip"; + +function Example() { + const tooltip = useTooltipState({ placement: "bottom-end" }); + return ( + <> + + Reference + + Tooltip + + ); +} +``` + +### Multiple tooltips + +Each group of `Tooltip` and `TooltipReference` should have its own corresponding +`useTooltipState`. + +```jsx +import { Button } from "reakit/Button"; +import { Tooltip, TooltipReference, useTooltipState } from "reakit/Tooltip"; + +function Example() { + const tooltip1 = useTooltipState(); + const tooltip2 = useTooltipState(); + return ( + <> + + Reference 1 + + Tooltip 1 + + Reference 2 + + Tooltip 2 + + ); +} +``` + +### Animating + +`Tooltip` uses [DisclosureContent](/docs/disclosure/) underneath, so you can use +the same approaches as described in the [Animating](/docs/disclosure/#animating) +section there. + +The only difference is that Reakit automatically adds inline styles to the +`Tooltip` element so that it's correctly positioned according to +`TooltipReference`. In this example, we're animating an inner wrapper element, +so we don't need to overwrite `Tooltip` positioning styles. + +```jsx +import { css } from "emotion"; +import { Button } from "reakit/Button"; +import { + useTooltipState, + Tooltip, + TooltipArrow, + TooltipReference, +} from "reakit/Tooltip"; + +const styles = css` + background-color: rgba(33, 33, 33, 0.9); + padding: 8px; + border-radius: 4px; + transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; + opacity: 0; + transform-origin: top center; + transform: translate3d(0, -20px, 0); + [data-enter] & { + opacity: 1; + transform: translate3d(0, 0, 0); + } +`; + +function Example() { + const tooltip = useTooltipState({ animated: 250 }); + return ( + <> + + Reference + + +
+ + Tooltip +
+
+ + ); +} +``` + +### Abstracting + +You can build your own `Tooltip` component with a different API on top of +Reakit. + +```jsx +import React from "react"; +import { + useTooltipState, + Tooltip as ReakitTooltip, + TooltipReference, +} from "reakit/Tooltip"; + +function Tooltip({ children, title, ...props }) { + const tooltip = useTooltipState(); + return ( + <> + + {referenceProps => React.cloneElement(children, referenceProps)} + + + {title} + + + ); +} + +function Example() { + return ( + + + + ); +} +``` + +## Accessibility + +- `Tooltip` has role `tooltip`. +- `TooltipReference` has `aria-describedby` referring to `Tooltip`. +- Escape hides the current visible tooltip. + +Learn more in [Accessibility](/docs/accessibility/). + +## Composition + +- `Tooltip` uses [DisclosureContent](/docs/disclosure/). +- `TooltipArrow` uses [PopoverArrow](/docs/popover/). +- `TooltipReference` uses [Role](/docs/role/). + +Learn more in [Composition](/docs/composition/#props-hooks). + +## Props + + + +### `useTooltipState` + +- **`baseId`** string + + ID that will serve as a base for all the items IDs. + +- **`visible`** boolean + + Whether it's visible or not. + +- **`animated`** number | boolean + + If `true`, `animating` will be set to `true` when `visible` is updated. It'll + wait for `stopAnimation` to be called or a CSS transition ends. If `animated` + is set to a `number`, `stopAnimation` will be called only after the same + number of milliseconds have passed. + +- **`placement`** + "auto-start" + | "auto" | "auto-end" | "top-start... + + Actual `placement`. + +- **`unstable_fixed`** ⚠️ boolean | + undefined + + Whether or not the popover should have `position` set to `fixed`. + +- **`unstable_flip`** ⚠️ boolean | + undefined + + Flip the popover's placement when it starts to overlap its reference element. + +- **`unstable_offset`** ⚠️ [string | + number, string | number] | undefined + + Offset between the reference and the popover: [main axis, alt axis]. Should + not be combined with `gutter`. + +- **`gutter`** number | undefined + + Offset between the reference and the popover on the main axis. Should not be + combined with `unstable_offset`. + +- **`unstable_preventOverflow`** ⚠️ + boolean | undefined + + Prevents popover from being positioned outside the boundary. + +### `Tooltip` + +- **`unstable_portal`** ⚠️ boolean | + undefined + + Whether or not the tooltip should be rendered within `Portal`. + +
5 state props + +> These props are returned by the state hook. You can spread them into this +> component (`{...state}`) or pass them separately. You can also provide these +> props from your own state logic. + +- **`baseId`** string + + ID that will serve as a base for all the items IDs. + +- **`visible`** boolean + + Whether it's visible or not. + +- **`animated`** number | boolean + + If `true`, `animating` will be set to `true` when `visible` is updated. It'll + wait for `stopAnimation` to be called or a CSS transition ends. If `animated` + is set to a `number`, `stopAnimation` will be called only after the same + number of milliseconds have passed. + +- **`animating`** boolean + + Whether it's animating or not. + +- **`stopAnimation`** () => void + + Stops animation. It's called automatically if there's a CSS transition. + +
+ +### `TooltipArrow` + +- **`size`** string | number | undefined + + Arrow's size + +
1 state props + +> These props are returned by the state hook. You can spread them into this +> component (`{...state}`) or pass them separately. You can also provide these +> props from your own state logic. + +- **`placement`** + "auto-start" + | "auto" | "auto-end" | "top-start... + + Actual `placement`. + +
+ +### `TooltipReference` + +
4 state props + +> These props are returned by the state hook. You can spread them into this +> component (`{...state}`) or pass them separately. You can also provide these +> props from your own state logic. + +- **`baseId`** string + + ID that will serve as a base for all the items IDs. + +- **`unstable_referenceRef`** ⚠️ + RefObject<HTMLElement | null> + + The reference element. + +- **`show`** () => void + + Changes the `visible` state to `true` + +- **`hide`** () => void + + Changes the `visible` state to `false` + +
diff --git a/src/tooltip/Tooltip.tsx b/src/tooltip/Tooltip.tsx new file mode 100644 index 000000000..c05df7591 --- /dev/null +++ b/src/tooltip/Tooltip.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { Portal } from "reakit"; + +import { + PopoverHTMLProps, + PopoverOptions, + usePopover, +} from "../popover/Popover"; + +import { TOOLTIP_KEYS } from "./__keys"; + +export type TooltipOptions = PopoverOptions & { + /** + * Whether or not the tooltip should be rendered within `Portal`. + */ + unstable_portal?: boolean; +}; + +export type TooltipHTMLProps = PopoverHTMLProps; + +export type TooltipProps = TooltipOptions & TooltipHTMLProps; + +export const useTooltip = createHook({ + name: "Tooltip", + compose: usePopover, + keys: TOOLTIP_KEYS, + + useOptions({ unstable_portal = true, ...options }) { + return { unstable_portal, ...options }; + }, + + useProps(options, htmlProps) { + const { + style: htmlStyle, + wrapElement: htmlWrapElement, + ...restHtmlProps + } = htmlProps; + + const wrapElement = React.useCallback( + (element: React.ReactNode) => { + if (options.unstable_portal) { + element = {element}; + } + + if (htmlWrapElement) { + return htmlWrapElement(element); + } + + return element; + }, + [options.unstable_portal, htmlWrapElement], + ); + + return { + style: { + pointerEvents: "none", + ...htmlStyle, + }, + wrapElement, + ...restHtmlProps, + }; + }, +}); + +export const Tooltip = createComponent({ + as: "div", + memo: true, + useHook: useTooltip, +}); diff --git a/src/tooltip/TooltipAnchor.tsx b/src/tooltip/TooltipAnchor.tsx new file mode 100644 index 000000000..89bcec3a6 --- /dev/null +++ b/src/tooltip/TooltipAnchor.tsx @@ -0,0 +1,40 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + PopoverAnchorHTMLProps, + PopoverAnchorOptions, + usePopoverAnchor, +} from "../popover"; + +import { TOOLTIP_ANCHOR_KEYS } from "./__keys"; +import { TooltipStateReturn } from "./TooltipState"; + +export type TooltipAnchorOptions = PopoverAnchorOptions & + Pick; + +export type TooltipAnchorHTMLProps = PopoverAnchorHTMLProps; + +export type TooltipAnchorProps = TooltipAnchorOptions & TooltipAnchorHTMLProps; + +export const useTooltipAnchor = createHook< + TooltipAnchorOptions, + TooltipAnchorHTMLProps +>({ + name: "TooltipAnchor", + compose: usePopoverAnchor, + keys: TOOLTIP_ANCHOR_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const TooltipAnchor = createComponent({ + as: "div", + memo: true, + useHook: useTooltipAnchor, +}); diff --git a/src/tooltip/TooltipArrow.ts b/src/tooltip/TooltipArrow.ts new file mode 100644 index 000000000..87eab0a21 --- /dev/null +++ b/src/tooltip/TooltipArrow.ts @@ -0,0 +1,39 @@ +import { createComponent } from "reakit-system/createComponent"; +import { createHook } from "reakit-system/createHook"; + +import { + PopoverArrowHTMLProps, + PopoverArrowOptions, + usePopoverArrow, +} from "../popover/PopoverArrow"; + +import { TOOLTIP_ARROW_KEYS } from "./__keys"; + +export type TooltipArrowOptions = PopoverArrowOptions; + +export type TooltipArrowHTMLProps = PopoverArrowHTMLProps; + +export type TooltipArrowProps = TooltipArrowOptions & TooltipArrowHTMLProps; + +export const useTooltipArrow = createHook< + TooltipArrowOptions, + TooltipArrowHTMLProps +>({ + name: "TooltipArrow", + compose: usePopoverArrow, + keys: TOOLTIP_ARROW_KEYS, + + useOptions(options) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const TooltipArrow = createComponent({ + as: "div", + memo: true, + useHook: useTooltipArrow, +}); diff --git a/src/tooltip/TooltipArrowContent.ts b/src/tooltip/TooltipArrowContent.ts new file mode 100644 index 000000000..6187cda60 --- /dev/null +++ b/src/tooltip/TooltipArrowContent.ts @@ -0,0 +1,39 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + PopoverArrowContentHTMLProps, + PopoverArrowContentOptions, + usePopoverArrowContent, +} from "../popover/PopoverArrowContent"; + +import { TOOLTIP_ARROW_CONTENT_KEYS } from "./__keys"; + +export type TooltipArrowContentOptions = PopoverArrowContentOptions; + +export type TooltipArrowContentHTMLProps = PopoverArrowContentHTMLProps; + +export type TooltipArrowContentProps = TooltipArrowContentOptions & + TooltipArrowContentHTMLProps; + +export const useTooltipArrowContent = createHook< + TooltipArrowContentOptions, + TooltipArrowContentHTMLProps +>({ + name: "TooltipArrowContent", + compose: usePopoverArrowContent, + keys: TOOLTIP_ARROW_CONTENT_KEYS, + + useOptions(options) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const TooltipArrowContent = createComponent({ + as: "div", + memo: true, + useHook: useTooltipArrowContent, +}); diff --git a/src/tooltip/TooltipContent.tsx b/src/tooltip/TooltipContent.tsx new file mode 100644 index 000000000..acfd8a494 --- /dev/null +++ b/src/tooltip/TooltipContent.tsx @@ -0,0 +1,65 @@ +import * as React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { getDocument, useForkRef } from "reakit-utils"; + +import { + DisclosureContentHTMLProps, + DisclosureContentOptions, + useDisclosureContent, +} from "../disclosure/DisclosureContent"; + +import globalState from "./__globalState"; +import { TOOLTIP_KEYS } from "./__keys"; +import { TooltipStateReturn } from "./TooltipState"; + +export type TooltipContentOptions = DisclosureContentOptions & + Pick, "setPopper" | "popper"> & {}; + +export type TooltipContentHTMLProps = DisclosureContentHTMLProps; + +export type TooltipContentProps = TooltipContentOptions & + TooltipContentHTMLProps; + +function globallyHideTooltipContentOnEscape(event: KeyboardEvent) { + if (event.defaultPrevented) return; + if (event.key === "Escape") { + globalState.show(null); + } +} + +export const useTooltipContent = createHook< + TooltipContentOptions, + TooltipContentHTMLProps +>({ + name: "TooltipContent", + compose: useDisclosureContent, + keys: TOOLTIP_KEYS, + + useOptions(options) { + return options; + }, + + useProps(options, { ref: htmlRef, style: htmlStyle, ...htmlProps }) { + React.useEffect(() => { + const document = getDocument(options.popper); + + document.addEventListener("keydown", globallyHideTooltipContentOnEscape); + }, [options.popper]); + + return { + ref: useForkRef(options.setPopper, htmlRef), + role: "tooltip", + style: { + pointerEvents: "none", + ...htmlStyle, + }, + ...htmlProps, + }; + }, +}); + +export const TooltipContent = createComponent({ + as: "div", + memo: true, + useHook: useTooltipContent, +}); diff --git a/src/tooltip/TooltipReference.ts b/src/tooltip/TooltipReference.ts new file mode 100644 index 000000000..d9d5cbba1 --- /dev/null +++ b/src/tooltip/TooltipReference.ts @@ -0,0 +1,94 @@ +import * as React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useForkRef, useLiveRef } from "reakit-utils"; + +import { TOOLTIP_REFERENCE_KEYS } from "./__keys"; +import { TooltipStateReturn } from "./TooltipState"; + +export type TooltipReferenceOptions = RoleOptions & + Pick, "setAnchor" | "baseId"> & + Pick; + +export type TooltipReferenceHTMLProps = RoleHTMLProps; + +export type TooltipReferenceProps = TooltipReferenceOptions & + TooltipReferenceHTMLProps; + +export const useTooltipReference = createHook< + TooltipReferenceOptions, + TooltipReferenceHTMLProps +>({ + name: "TooltipReference", + compose: useRole, + keys: TOOLTIP_REFERENCE_KEYS, + + useProps( + options, + { + ref: htmlRef, + onFocus: htmlOnFocus, + onBlur: htmlOnBlur, + onMouseEnter: htmlOnMouseEnter, + onMouseLeave: htmlOnMouseLeave, + ...htmlProps + }, + ) { + const onFocusRef = useLiveRef(htmlOnFocus); + const onBlurRef = useLiveRef(htmlOnBlur); + const onMouseEnterRef = useLiveRef(htmlOnMouseEnter); + const onMouseLeaveRef = useLiveRef(htmlOnMouseLeave); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusRef.current?.(event); + if (event.defaultPrevented) return; + options.show?.(); + }, + [options.show], + ); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + onBlurRef.current?.(event); + if (event.defaultPrevented) return; + options.hide?.(); + }, + [options.hide], + ); + + const onMouseEnter = React.useCallback( + (event: React.MouseEvent) => { + onMouseEnterRef.current?.(event); + if (event.defaultPrevented) return; + options.show?.(); + }, + [options.show], + ); + + const onMouseLeave = React.useCallback( + (event: React.MouseEvent) => { + onMouseLeaveRef.current?.(event); + if (event.defaultPrevented) return; + options.hide?.(); + }, + [options.hide], + ); + + return { + ref: useForkRef(options.setAnchor, htmlRef), + tabIndex: 0, + onFocus, + onBlur, + onMouseEnter, + onMouseLeave, + "aria-describedby": options.baseId, + ...htmlProps, + }; + }, +}); + +export const TooltipReference = createComponent({ + as: "div", + useHook: useTooltipReference, +}); diff --git a/src/tooltip/TooltipState.ts b/src/tooltip/TooltipState.ts new file mode 100644 index 000000000..d6c5d9f94 --- /dev/null +++ b/src/tooltip/TooltipState.ts @@ -0,0 +1,116 @@ +import * as React from "react"; + +import { + PopoverActions, + PopoverInitialState, + PopoverState, + PopoverStateReturn, + usePopoverState, +} from "../popover/PopoverState"; + +import globalState from "./__globalState"; + +export type TooltipState = Omit & { + /** + * @private + */ + unstable_timeout: number; +}; + +export type TooltipActions = Omit & { + /** + * @private + */ + unstable_setTimeout: React.Dispatch< + React.SetStateAction + >; +}; + +export type TooltipInitialState = Omit & + Pick, "unstable_timeout">; + +export type TooltipStateReturn = Omit< + PopoverStateReturn, + "modal" | "setModal" +> & + TooltipState & + TooltipActions; + +export function useTooltipState( + props: TooltipInitialState = {}, +): TooltipStateReturn { + const { unstable_timeout: initialTimeout = 0, ...restProps } = props; + const [timeout, setTimeout] = React.useState(initialTimeout); + const showTimeout = React.useRef(null); + const hideTimeout = React.useRef(null); + + const { modal, setModal, ...popover } = usePopoverState({ + ...restProps, + }); + + const clearTimeouts = React.useCallback(() => { + if (showTimeout.current !== null) { + window.clearTimeout(showTimeout.current); + } + if (hideTimeout.current !== null) { + window.clearTimeout(hideTimeout.current); + } + }, []); + + const hide = React.useCallback(() => { + clearTimeouts(); + popover.hide(); + // Let's give some time so people can move from a reference to another + // and still show tooltips immediately + hideTimeout.current = window.setTimeout(() => { + globalState.hide(popover.baseId); + }, timeout); + }, [clearTimeouts, popover.hide, timeout, popover.baseId]); + + const show = React.useCallback(() => { + clearTimeouts(); + if (!timeout || globalState.currentTooltipId) { + // If there's no timeout or a tooltip visible already, we can show this + // immediately + globalState.show(popover.baseId); + popover.show(); + } else { + // There may be a reference with focus whose tooltip is still not visible + // In this case, we want to update it before it gets shown. + globalState.show(null); + // Otherwise, wait a little bit to show the tooltip + showTimeout.current = window.setTimeout(() => { + globalState.show(popover.baseId); + popover.show(); + }, timeout); + } + }, [clearTimeouts, timeout, popover.show, popover.baseId]); + + React.useEffect(() => { + return globalState.subscribe(id => { + if (id !== popover.baseId) { + clearTimeouts(); + if (popover.visible) { + // Make sure there will be only one tooltip visible + popover.hide(); + } + } + }); + }, [popover.baseId, clearTimeouts, popover.visible, popover.hide]); + + React.useEffect( + () => () => { + clearTimeouts(); + globalState.hide(popover.baseId); + }, + [clearTimeouts, popover.baseId], + ); + + return { + ...popover, + hide, + show, + unstable_timeout: timeout, + unstable_setTimeout: setTimeout, + }; +} diff --git a/src/tooltip/TooltipTrigger.ts b/src/tooltip/TooltipTrigger.ts new file mode 100644 index 000000000..06ca7d20c --- /dev/null +++ b/src/tooltip/TooltipTrigger.ts @@ -0,0 +1,93 @@ +import * as React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useLiveRef } from "reakit-utils"; + +import { TOOLTIP_TRIGGER_KEYS } from "./__keys"; +import { TooltipStateReturn } from "./TooltipState"; + +export type TooltipTriggerOptions = RoleOptions & + Pick, "baseId"> & + Pick; + +export type TooltipTriggerHTMLProps = RoleHTMLProps; + +export type TooltipTriggerProps = TooltipTriggerOptions & + TooltipTriggerHTMLProps; + +export const useTooltipTrigger = createHook< + TooltipTriggerOptions, + TooltipTriggerHTMLProps +>({ + name: "TooltipTrigger", + compose: useRole, + keys: TOOLTIP_TRIGGER_KEYS, + + useProps( + options, + { + ref: htmlRef, + onFocus: htmlOnFocus, + onBlur: htmlOnBlur, + onMouseEnter: htmlOnMouseEnter, + onMouseLeave: htmlOnMouseLeave, + ...htmlProps + }, + ) { + const onFocusRef = useLiveRef(htmlOnFocus); + const onBlurRef = useLiveRef(htmlOnBlur); + const onMouseEnterRef = useLiveRef(htmlOnMouseEnter); + const onMouseLeaveRef = useLiveRef(htmlOnMouseLeave); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusRef.current?.(event); + if (event.defaultPrevented) return; + options.show?.(); + }, + [options.show], + ); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + onBlurRef.current?.(event); + if (event.defaultPrevented) return; + options.hide?.(); + }, + [options.hide], + ); + + const onMouseEnter = React.useCallback( + (event: React.MouseEvent) => { + onMouseEnterRef.current?.(event); + if (event.defaultPrevented) return; + options.show?.(); + }, + [options.show], + ); + + const onMouseLeave = React.useCallback( + (event: React.MouseEvent) => { + onMouseLeaveRef.current?.(event); + if (event.defaultPrevented) return; + options.hide?.(); + }, + [options.hide], + ); + + return { + tabIndex: 0, + onFocus, + onBlur, + onMouseEnter, + onMouseLeave, + "aria-describedby": options.baseId, + ...htmlProps, + }; + }, +}); + +export const TooltipTrigger = createComponent({ + as: "div", + useHook: useTooltipTrigger, +}); diff --git a/src/tooltip/__globalState.ts b/src/tooltip/__globalState.ts new file mode 100644 index 000000000..7bea68951 --- /dev/null +++ b/src/tooltip/__globalState.ts @@ -0,0 +1,24 @@ +type Listener = (id: string | null) => void; + +const state = { + currentTooltipId: null as string | null, + listeners: new Set(), + subscribe(listener: Listener) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }, + show(id: string | null) { + this.currentTooltipId = id; + this.listeners.forEach(listener => listener(id)); + }, + hide(id: string) { + if (this.currentTooltipId === id) { + this.currentTooltipId = null; + this.listeners.forEach(listener => listener(null)); + } + }, +}; + +export default state; diff --git a/src/tooltip/__keys.ts b/src/tooltip/__keys.ts new file mode 100644 index 000000000..0596782a2 --- /dev/null +++ b/src/tooltip/__keys.ts @@ -0,0 +1,57 @@ +// Automatically generated +export const USE_TOOLTIP_STATE_KEYS = [ + "baseId", + "visible", + "side", + "align", + "sideOffset", + "alignOffset", + "arrowOffset", + "collisionTolerance", + "defaultVisible", + "onVisibleChange", + "enableCollisionsDetection", + "unstable_timeout", +] as const; +export const TOOLTIP_STATE_KEYS = [ + "baseId", + "visible", + "side", + "align", + "sideOffset", + "alignOffset", + "arrowOffset", + "collisionTolerance", + "unstable_idCountRef", + "disclosureRef", + "popperStyles", + "arrowStyles", + "placedSide", + "placedAlign", + "anchor", + "popper", + "arrow", + "setBaseId", + "show", + "hide", + "toggle", + "setVisible", + "setSide", + "setAlign", + "setSideOffset", + "setAlignOffset", + "setArrowOffset", + "setCollisionTolerance", + "setAnchor", + "setPopper", + "setArrow", + "unstable_timeout", + "unstable_setTimeout", +] as const; +export const TOOLTIP_KEYS = [...TOOLTIP_STATE_KEYS, "unstable_portal"] as const; +export const TOOLTIP_ANCHOR_KEYS = TOOLTIP_STATE_KEYS; +export const TOOLTIP_ARROW_KEYS = TOOLTIP_ANCHOR_KEYS; +export const TOOLTIP_ARROW_CONTENT_KEYS = TOOLTIP_ARROW_KEYS; +export const TOOLTIP_CONTENT_KEYS = TOOLTIP_ARROW_CONTENT_KEYS; +export const TOOLTIP_REFERENCE_KEYS = TOOLTIP_CONTENT_KEYS; +export const TOOLTIP_TRIGGER_KEYS = TOOLTIP_REFERENCE_KEYS; diff --git a/src/tooltip/index.ts b/src/tooltip/index.ts new file mode 100644 index 000000000..690c7f71c --- /dev/null +++ b/src/tooltip/index.ts @@ -0,0 +1,8 @@ +export * from "./Tooltip"; +export * from "./TooltipAnchor"; +export * from "./TooltipArrow"; +export * from "./TooltipArrowContent"; +export * from "./TooltipContent"; +export * from "./TooltipReference"; +export * from "./TooltipState"; +export * from "./TooltipTrigger"; diff --git a/src/tooltip/stories/TooltipBasic.component.tsx b/src/tooltip/stories/TooltipBasic.component.tsx new file mode 100644 index 000000000..0f7e0b572 --- /dev/null +++ b/src/tooltip/stories/TooltipBasic.component.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; + +import { Tooltip } from "../Tooltip"; +import { TooltipContent } from "../TooltipContent"; +import { TooltipReference } from "../TooltipReference"; +import { TooltipInitialState, useTooltipState } from "../TooltipState"; + +export type TooltipBasicProps = TooltipInitialState & {}; + +export const TooltipBasic: React.FC = props => { + const state = useTooltipState(props); + + return ( +
+ + Open + + + + +
Tooltip
+
+
+ +
+ ); +}; diff --git a/src/tooltip/stories/TooltipBasic.css b/src/tooltip/stories/TooltipBasic.css new file mode 100644 index 000000000..919e53a36 --- /dev/null +++ b/src/tooltip/stories/TooltipBasic.css @@ -0,0 +1,38 @@ +.content { + background-color: #222; + color: white; + padding: 5px 10px; + border-radius: 5px; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadeIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadeOut 200ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/tooltip/stories/TooltipBasic.stories.tsx b/src/tooltip/stories/TooltipBasic.stories.tsx new file mode 100644 index 000000000..8b30dd510 --- /dev/null +++ b/src/tooltip/stories/TooltipBasic.stories.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipBasicCss"; +import js from "./templates/TooltipBasicJsx"; +import ts from "./templates/TooltipBasicTsx"; +import { TooltipBasic } from "./TooltipBasic.component"; + +import "./TooltipBasic.css"; + +export default { + component: TooltipBasic, + title: "Tooltip/Basic", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ; + +export const NoCollisionDetection: Story = args => ( + +); diff --git a/src/tooltip/stories/TooltipCustomAnchor.component.tsx b/src/tooltip/stories/TooltipCustomAnchor.component.tsx new file mode 100644 index 000000000..ee2e48484 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomAnchor.component.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; + +import { Tooltip } from "../Tooltip"; +import { TooltipAnchor } from "../TooltipAnchor"; +import { TooltipArrow } from "../TooltipArrow"; +import { TooltipArrowContent } from "../TooltipArrowContent"; +import { TooltipContent } from "../TooltipContent"; +import { TooltipInitialState, useTooltipState } from "../TooltipState"; +import { TooltipTrigger } from "../TooltipTrigger"; + +export type TooltipCustomAnchorProps = TooltipInitialState & {}; + +export const TooltipCustomAnchor: React.FC = + props => { + const state = useTooltipState(props); + + return ( +
+ + Tooltip Trigger + Open + + + + +
Tooltip Content
+ + + + + + + +
+
+
+ ); + }; diff --git a/src/tooltip/stories/TooltipCustomAnchor.css b/src/tooltip/stories/TooltipCustomAnchor.css new file mode 100644 index 000000000..e2cf15370 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomAnchor.css @@ -0,0 +1,47 @@ +.content { + background-color: #222; + color: white; + padding: 5px; + border-radius: 5px; +} + +.arrow { + display: inline-block; + vertical-align: top; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadesIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadesOut 200ms ease-in-out; +} + +@keyframes fadesIn { + from { + opacity: 0; + transform: translate(0, 10px); + } + + to { + opacity: 1; + transform: translate(0, 0px); + } +} + +@keyframes fadesOut { + from { + opacity: 1; + transform: translate(0, 0px); + } + + to { + opacity: 0; + transform: translate(0, +10px); + } +} diff --git a/src/tooltip/stories/TooltipCustomAnchor.stories.tsx b/src/tooltip/stories/TooltipCustomAnchor.stories.tsx new file mode 100644 index 000000000..b02883abd --- /dev/null +++ b/src/tooltip/stories/TooltipCustomAnchor.stories.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipCustomAnchorCss"; +import js from "./templates/TooltipCustomAnchorJsx"; +import ts from "./templates/TooltipCustomAnchorTsx"; +import { TooltipCustomAnchor } from "./TooltipCustomAnchor.component"; + +import "./TooltipCustomAnchor.css"; + +export default { + component: TooltipCustomAnchor, + title: "Tooltip/CustomAnchor", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ( + +); diff --git a/src/tooltip/stories/TooltipCustomContent.component.tsx b/src/tooltip/stories/TooltipCustomContent.component.tsx new file mode 100644 index 000000000..d8a7d0a9d --- /dev/null +++ b/src/tooltip/stories/TooltipCustomContent.component.tsx @@ -0,0 +1,141 @@ +import * as React from "react"; + +import { Tooltip } from "../Tooltip"; +import { TooltipArrow } from "../TooltipArrow"; +import { TooltipArrowContent } from "../TooltipArrowContent"; +import { TooltipContent } from "../TooltipContent"; +import { TooltipReference } from "../TooltipReference"; +import { TooltipInitialState, useTooltipState } from "../TooltipState"; + +export type TooltipCustomContentProps = {}; + +export const TooltipCustomContent: React.FC = + props => { + return ( +
+ +

Some heading

+
+ + +

Some paragraph

+
+ + +
    +
  • One
  • +
  • Two
  • +
  • Three
  • +
+
+ + +
+ Lorem ipsum dolor sit amet consectetur, adipisicing elit. Harum, + quae qui. Magnam delectus ex totam repellat amet distinctio unde, + porro architecto voluptatibus nemo et nisi, voluptatem eligendi + earum autem fugit. +
+
+ + +
+ +
Jason Todd
+
+
+ + + {/* @ts-ignore */} + + + + + View in Modulz + + + +
+ +
+ +
+ +
+ +
+
+ + +

+ Start video call + + press{" "} + + c + + +

+
+
+ ); + }; + +export type TooltipBasicProps = TooltipInitialState & { + label?: string; +}; + +export const TooltipBasic: React.FC = props => { + const { label, children, ...restProps } = props; + const state = useTooltipState({ + sideOffset: 5, + arrowOffset: 10, + ...restProps, + }); + + return ( + <> + + {label} + + + + + {children} + + + + + + + + + + + ); +}; diff --git a/src/tooltip/stories/TooltipCustomContent.css b/src/tooltip/stories/TooltipCustomContent.css new file mode 100644 index 000000000..85286be70 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomContent.css @@ -0,0 +1,45 @@ +.trigger { + margin: 5px; + border: 1px solid black; + background: transparent; +} + +.content { + background-color: #222; + color: white; + padding: 5px 10px; + border-radius: 5px; + max-width: 300px; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadeIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadeOut 200ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/tooltip/stories/TooltipCustomContent.stories.tsx b/src/tooltip/stories/TooltipCustomContent.stories.tsx new file mode 100644 index 000000000..e5e6b324c --- /dev/null +++ b/src/tooltip/stories/TooltipCustomContent.stories.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipCustomContentCss"; +import js from "./templates/TooltipCustomContentJsx"; +import ts from "./templates/TooltipCustomContentTsx"; +import { TooltipCustomContent } from "./TooltipCustomContent.component"; + +import "./TooltipCustomContent.css"; + +export default { + component: TooltipCustomContent, + title: "Tooltip/CustomContent", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ; diff --git a/src/tooltip/stories/TooltipCustomDuration.component.tsx b/src/tooltip/stories/TooltipCustomDuration.component.tsx new file mode 100644 index 000000000..810de9517 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomDuration.component.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; + +import { Tooltip } from "../Tooltip"; +import { TooltipArrow } from "../TooltipArrow"; +import { TooltipArrowContent } from "../TooltipArrowContent"; +import { TooltipContent } from "../TooltipContent"; +import { TooltipReference } from "../TooltipReference"; +import { TooltipInitialState, useTooltipState } from "../TooltipState"; + +export type TooltipCustomDurationProps = {}; + +export const TooltipCustomDuration: React.FC = + props => { + return ( + <> +

Duration

+

Default (Instant)

+
+ + + +
+ +

Custom (750ms)

+
+ + + +
+ + ); + }; + +export type TooltipBasicProps = TooltipInitialState & { + label?: string; +}; + +export const TooltipBasic: React.FC = props => { + const { label, children, ...restProps } = props; + const state = useTooltipState({ + sideOffset: 5, + arrowOffset: 10, + ...restProps, + }); + + return ( + <> + + TooltipTrigger + + + + + TooltipContent + + + + + + + + + + + ); +}; diff --git a/src/tooltip/stories/TooltipCustomDuration.css b/src/tooltip/stories/TooltipCustomDuration.css new file mode 100644 index 000000000..85286be70 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomDuration.css @@ -0,0 +1,45 @@ +.trigger { + margin: 5px; + border: 1px solid black; + background: transparent; +} + +.content { + background-color: #222; + color: white; + padding: 5px 10px; + border-radius: 5px; + max-width: 300px; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadeIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadeOut 200ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/tooltip/stories/TooltipCustomDuration.stories.tsx b/src/tooltip/stories/TooltipCustomDuration.stories.tsx new file mode 100644 index 000000000..2754d8666 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomDuration.stories.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipCustomDurationCss"; +import js from "./templates/TooltipCustomDurationJsx"; +import ts from "./templates/TooltipCustomDurationTsx"; +import { TooltipCustomDuration } from "./TooltipCustomDuration.component"; + +import "./TooltipCustomDuration.css"; + +export default { + component: TooltipCustomDuration, + title: "Tooltip/CustomDuration", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ; diff --git a/src/tooltip/stories/TooltipPositions.component.tsx b/src/tooltip/stories/TooltipPositions.component.tsx new file mode 100644 index 000000000..e6591f0f1 --- /dev/null +++ b/src/tooltip/stories/TooltipPositions.component.tsx @@ -0,0 +1,156 @@ +import * as React from "react"; + +import { Tooltip } from "../Tooltip"; +import { TooltipArrow } from "../TooltipArrow"; +import { TooltipArrowContent } from "../TooltipArrowContent"; +import { TooltipContent } from "../TooltipContent"; +import { TooltipReference } from "../TooltipReference"; +import { TooltipInitialState, useTooltipState } from "../TooltipState"; + +export type TooltipPositionsProps = TooltipInitialState & {}; + +export const TooltipPositions: React.FC = props => { + return ( +
+
+ + + + + + + + + + + + + + + +
+
+ ); +}; + +export type TooltipBasicProps = TooltipInitialState & { + style?: React.CSSProperties; + label?: string; +}; + +export const TooltipBasic: React.FC = props => { + const { label, style, ...restProps } = props; + const state = useTooltipState({ + sideOffset: 5, + arrowOffset: 10, + ...restProps, + }); + + return ( + <> + + {label} + + + + +
Tooltip
+ + + + + + + +
+
+ + ); +}; diff --git a/src/tooltip/stories/TooltipPositions.css b/src/tooltip/stories/TooltipPositions.css new file mode 100644 index 000000000..5eba96f6a --- /dev/null +++ b/src/tooltip/stories/TooltipPositions.css @@ -0,0 +1,44 @@ +.trigger { + margin: 5px; + border: 1px solid black; + background: transparent; +} + +.content { + background-color: #222; + color: white; + padding: 5px 10px; + border-radius: 5px; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadeIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadeOut 200ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/tooltip/stories/TooltipPositions.stories.tsx b/src/tooltip/stories/TooltipPositions.stories.tsx new file mode 100644 index 000000000..9fa4b7d5a --- /dev/null +++ b/src/tooltip/stories/TooltipPositions.stories.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipPositionsCss"; +import js from "./templates/TooltipPositionsJsx"; +import ts from "./templates/TooltipPositionsTsx"; +import { TooltipPositions } from "./TooltipPositions.component"; + +import "./TooltipPositions.css"; + +export default { + component: TooltipPositions, + title: "Tooltip/Positions", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ; From bd3596962d9f9371ba240b81cdf21f04ec3bf4ed Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Wed, 17 Nov 2021 20:01:35 +0530 Subject: [PATCH 09/13] =?UTF-8?q?refactor(tooltip):=20=E2=99=BB=EF=B8=8F?= =?UTF-8?q?=20update=20tooltip=20effect=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tooltip/TooltipReference.ts | 21 +++++++++++---------- src/tooltip/TooltipState.ts | 3 +++ src/tooltip/TooltipTrigger.ts | 17 +++++++++-------- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/tooltip/TooltipReference.ts b/src/tooltip/TooltipReference.ts index d9d5cbba1..fc5ff4dcf 100644 --- a/src/tooltip/TooltipReference.ts +++ b/src/tooltip/TooltipReference.ts @@ -34,6 +34,7 @@ export const useTooltipReference = createHook< ...htmlProps }, ) { + const { show, hide, baseId, setAnchor } = options; const onFocusRef = useLiveRef(htmlOnFocus); const onBlurRef = useLiveRef(htmlOnBlur); const onMouseEnterRef = useLiveRef(htmlOnMouseEnter); @@ -43,46 +44,46 @@ export const useTooltipReference = createHook< (event: React.FocusEvent) => { onFocusRef.current?.(event); if (event.defaultPrevented) return; - options.show?.(); + show?.(); }, - [options.show], + [onFocusRef, show], ); const onBlur = React.useCallback( (event: React.FocusEvent) => { onBlurRef.current?.(event); if (event.defaultPrevented) return; - options.hide?.(); + hide?.(); }, - [options.hide], + [hide, onBlurRef], ); const onMouseEnter = React.useCallback( (event: React.MouseEvent) => { onMouseEnterRef.current?.(event); if (event.defaultPrevented) return; - options.show?.(); + show?.(); }, - [options.show], + [onMouseEnterRef, show], ); const onMouseLeave = React.useCallback( (event: React.MouseEvent) => { onMouseLeaveRef.current?.(event); if (event.defaultPrevented) return; - options.hide?.(); + hide?.(); }, - [options.hide], + [hide, onMouseLeaveRef], ); return { - ref: useForkRef(options.setAnchor, htmlRef), + ref: useForkRef(setAnchor, htmlRef), tabIndex: 0, onFocus, onBlur, onMouseEnter, onMouseLeave, - "aria-describedby": options.baseId, + "aria-describedby": baseId, ...htmlProps, }; }, diff --git a/src/tooltip/TooltipState.ts b/src/tooltip/TooltipState.ts index d6c5d9f94..9710b60fa 100644 --- a/src/tooltip/TooltipState.ts +++ b/src/tooltip/TooltipState.ts @@ -65,6 +65,7 @@ export function useTooltipState( hideTimeout.current = window.setTimeout(() => { globalState.hide(popover.baseId); }, timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [clearTimeouts, popover.hide, timeout, popover.baseId]); const show = React.useCallback(() => { @@ -84,6 +85,7 @@ export function useTooltipState( popover.show(); }, timeout); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [clearTimeouts, timeout, popover.show, popover.baseId]); React.useEffect(() => { @@ -96,6 +98,7 @@ export function useTooltipState( } } }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [popover.baseId, clearTimeouts, popover.visible, popover.hide]); React.useEffect( diff --git a/src/tooltip/TooltipTrigger.ts b/src/tooltip/TooltipTrigger.ts index 06ca7d20c..f22a83060 100644 --- a/src/tooltip/TooltipTrigger.ts +++ b/src/tooltip/TooltipTrigger.ts @@ -34,6 +34,7 @@ export const useTooltipTrigger = createHook< ...htmlProps }, ) { + const { show, hide, baseId } = options; const onFocusRef = useLiveRef(htmlOnFocus); const onBlurRef = useLiveRef(htmlOnBlur); const onMouseEnterRef = useLiveRef(htmlOnMouseEnter); @@ -45,34 +46,34 @@ export const useTooltipTrigger = createHook< if (event.defaultPrevented) return; options.show?.(); }, - [options.show], + [onFocusRef, options], ); const onBlur = React.useCallback( (event: React.FocusEvent) => { onBlurRef.current?.(event); if (event.defaultPrevented) return; - options.hide?.(); + hide?.(); }, - [options.hide], + [hide, onBlurRef], ); const onMouseEnter = React.useCallback( (event: React.MouseEvent) => { onMouseEnterRef.current?.(event); if (event.defaultPrevented) return; - options.show?.(); + show?.(); }, - [options.show], + [onMouseEnterRef, show], ); const onMouseLeave = React.useCallback( (event: React.MouseEvent) => { onMouseLeaveRef.current?.(event); if (event.defaultPrevented) return; - options.hide?.(); + hide?.(); }, - [options.hide], + [hide, onMouseLeaveRef], ); return { @@ -81,7 +82,7 @@ export const useTooltipTrigger = createHook< onBlur, onMouseEnter, onMouseLeave, - "aria-describedby": options.baseId, + "aria-describedby": baseId, ...htmlProps, }; }, From 5495b88752fa6fa499d1c4aafc555ad64e26fbbb Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Sat, 20 Nov 2021 00:05:14 +0530 Subject: [PATCH 10/13] =?UTF-8?q?feat(disclosure):=20=E2=9C=A8=20added=20t?= =?UTF-8?q?ransition=20support=20along=20with=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final Drawer component with transition support added --- package.json | 4 + src/dialog/stories/DialogBasic.component.tsx | 9 +- src/dialog/stories/DialogBasic.css | 8 +- src/disclosure/DisclosureContent.tsx | 109 +++- src/disclosure/__keys.ts | 2 + .../stories/DisclosureBasic.component.tsx | 2 +- src/disclosure/stories/DisclosureBasic.css | 29 +- .../DisclosureHorizontal.component.tsx | 2 +- src/drawer/Drawer.ts | 3 +- src/drawer/DrawerBackdrop.ts | 35 ++ src/drawer/DrawerCloseButton.ts | 29 - src/drawer/DrawerDisclosure.tsx | 38 ++ src/drawer/DrawerState.ts | 24 + src/drawer/__keys.ts | 25 +- src/drawer/index.ts | 11 +- src/drawer/stories/DrawerBasic.component.tsx | 26 +- src/drawer/stories/DrawerBasic.stories.tsx | 6 +- src/index.ts | 1 + src/popover/Popover.tsx | 3 +- src/popover/PopoverState.ts | 7 +- src/popover/__keys.ts | 13 - src/popover/index.ts | 2 +- src/popover/popper-core.ts | 549 ------------------ src/popover/rect.ts | 109 ---- .../stories/PopoverBasic.component.tsx | 19 +- src/popover/stories/PopoverBasic.stories.tsx | 3 +- .../stories/PopoverCollision.component.tsx | 17 +- src/popover/useRect.ts | 27 - src/popover/useSize.ts | 60 -- .../stories/TooltipBasic.component.tsx | 13 +- .../stories/TooltipCustomAnchor.component.tsx | 19 +- .../TooltipCustomContent.component.tsx | 17 +- .../TooltipCustomDuration.component.tsx | 17 +- .../stories/TooltipPositions.component.tsx | 17 +- yarn.lock | 58 ++ 35 files changed, 410 insertions(+), 903 deletions(-) create mode 100644 src/drawer/DrawerBackdrop.ts delete mode 100644 src/drawer/DrawerCloseButton.ts create mode 100644 src/drawer/DrawerDisclosure.tsx create mode 100644 src/drawer/DrawerState.ts delete mode 100644 src/popover/popper-core.ts delete mode 100644 src/popover/rect.ts delete mode 100644 src/popover/useRect.ts delete mode 100644 src/popover/useSize.ts diff --git a/package.json b/package.json index 6d842d9bf..6fa452dfe 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,10 @@ "@chakra-ui/hooks": "^1.6.1", "@chakra-ui/react-utils": "^1.1.2", "@chakra-ui/utils": "^1.8.3", + "@radix-ui/popper": "^0.1.0", + "@radix-ui/react-presence": "^0.1.1", + "@radix-ui/react-use-rect": "^0.1.1", + "@radix-ui/react-use-size": "^0.1.0", "@react-aria/i18n": "^3.3.2", "@react-aria/interactions": "^3.6.0", "@react-aria/spinbutton": "^3.0.1", diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx index 6201150ea..8f377de17 100644 --- a/src/dialog/stories/DialogBasic.component.tsx +++ b/src/dialog/stories/DialogBasic.component.tsx @@ -18,8 +18,13 @@ export const DialogBasic: React.FC = props => { return ( <> Open dialog - - + + Welcome to Reakit!
diff --git a/src/dialog/stories/DialogBasic.css b/src/dialog/stories/DialogBasic.css index ac12370fc..6bd24ef11 100644 --- a/src/dialog/stories/DialogBasic.css +++ b/src/dialog/stories/DialogBasic.css @@ -64,20 +64,20 @@ @keyframes slideIn { from { - opacity: 0; + transform: translate(0, -10px); } to { - opacity: 1; + transform: translate(0, 0px); } } @keyframes slideOut { from { - opacity: 1; + transform: translate(0, 0px); } to { - opacity: 0; + transform: translate(0, -10px); } } diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index a2b3ba2f1..944cd1683 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -2,7 +2,8 @@ import * as React from "react"; import { createComponent } from "reakit-system"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { useForkRef } from "reakit-utils"; +import { isSelfTarget, useForkRef, useLiveRef } from "reakit-utils"; +import { useSafeLayoutEffect } from "@chakra-ui/hooks"; import { usePresenceState } from "../presence"; import { createComposableHook } from "../system"; @@ -14,6 +15,16 @@ export type DisclosureContentOptions = BoxOptions & Pick & { present: boolean; presenceRef: ((value: any) => void) | null; + + /** + * Whether it uses animation or not. + */ + animation: boolean; + + /** + * Whether it uses animation or not. + */ + transition: boolean; }; export type DisclosureContentHTMLProps = BoxHTMLProps; @@ -21,6 +32,8 @@ export type DisclosureContentHTMLProps = BoxHTMLProps; export type DisclosureContentProps = DisclosureContentOptions & DisclosureContentHTMLProps; +type TransitionState = "enter" | "leave" | null; + export const disclosureComposableContent = createComposableHook< DisclosureContentOptions, DisclosureContentHTMLProps @@ -40,8 +53,14 @@ export const disclosureComposableContent = createComposableHook< }, useProps(options, htmlProps) { - const { visible, baseId, presenceRef, present } = options; - const { ref: htmlRef, style: htmlStyle, ...restHtmlProps } = htmlProps; + const { visible, baseId, presenceRef, present, transition, animation } = + options; + const { + ref: htmlRef, + style: htmlStyle, + onTransitionEnd: htmlOnTransitionEnd, + ...restHtmlProps + } = htmlProps; const ref = React.useRef(null); const [isPresent, setIsPresent] = React.useState(present); const heightRef = React.useRef(0); @@ -49,9 +68,53 @@ export const disclosureComposableContent = createComposableHook< const widthRef = React.useRef(0); const width = widthRef.current; + const [transitionState, setTransitionState] = + React.useState(null); + const [transitioning, setTransitioning] = React.useState(false); + const lastVisible = useLastValue(visible); + const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); + + const visibleHasChanged = + lastVisible.current != null && lastVisible.current !== visible; + + if (transition && !transitioning && visibleHasChanged) { + // Sets transitioning to true when when visible is updated + setTransitioning(true); + } + + const raf = React.useRef(0); + + React.useEffect(() => { + if (!transition) return; + + // Double RAF is needed so the browser has enough time to paint the + // default styles before processing the `data-enter` attribute. Otherwise + // it wouldn't be considered a transition. + // See https://github.com/reakit/reakit/issues/643 + raf.current = window.requestAnimationFrame(() => { + raf.current = window.requestAnimationFrame(() => { + if (visible) { + if (!transitioning) return; + + setTransitionState("enter"); + } else if (transitioning) { + setTransitionState("leave"); + } else { + setTransitionState(null); + } + }); + }); + + return () => window.cancelAnimationFrame(raf.current); + }, [visible, transitioning, transition]); + // when opening we want it to immediately open to retrieve dimensions // when closing we delay `present` to retrieve dimensions before closing const isVisible = visible || isPresent; + const isHidden = + (animation && !isVisible) || + (transition && !visible && !transitioning) || + (!animation && !transition && !isVisible); React.useLayoutEffect(() => { const node = ref.current; @@ -84,16 +147,38 @@ export const disclosureComposableContent = createComposableHook< const style = { "--content-height": height ? `${height}px` : undefined, "--content-width": width ? `${width}px` : undefined, - display: isVisible ? undefined : "none", + display: isHidden ? "none" : undefined, ...htmlStyle, }; + const onTransitionEnd = React.useCallback( + (event: React.TransitionEvent) => { + onTransitionEndRef.current?.(event); + if (!isSelfTarget(event)) return; + if (!transition) return; + if (!transitioning) return; + + // Ignores number animated + setTransitioning(false); + }, + [onTransitionEndRef, transition, transitioning], + ); + return { ref: useForkRef(presenceRef, useForkRef(ref, htmlRef)), - "data-enter": visible ? "" : undefined, - "data-leave": !visible ? "" : undefined, id: baseId, - hidden: !isVisible, + hidden: isHidden, + // "data-enter": visible ? "" : undefined, + // "data-leave": !visible ? "" : undefined, + "data-enter": + (transition && transitionState === "enter") || (animation && visible) + ? "" + : undefined, + "data-leave": + (transition && transitionState === "leave") || (animation && !visible) + ? "" + : undefined, + onTransitionEnd, style, ...restHtmlProps, }; @@ -107,3 +192,13 @@ export const DisclosureContent = createComponent({ memo: true, useHook: useDisclosureContent, }); + +function useLastValue(value: T) { + const lastValue = React.useRef(null); + + useSafeLayoutEffect(() => { + lastValue.current = value; + }, [value]); + + return lastValue; +} diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts index ca60a6507..301ab64e3 100644 --- a/src/disclosure/__keys.ts +++ b/src/disclosure/__keys.ts @@ -20,4 +20,6 @@ export const DISCLOSURE_CONTENT_KEYS = [ ...DISCLOSURE_KEYS, "present", "presenceRef", + "animation", + "transition", ] as const; diff --git a/src/disclosure/stories/DisclosureBasic.component.tsx b/src/disclosure/stories/DisclosureBasic.component.tsx index 84125ad55..809f2c597 100644 --- a/src/disclosure/stories/DisclosureBasic.component.tsx +++ b/src/disclosure/stories/DisclosureBasic.component.tsx @@ -15,7 +15,7 @@ export const DisclosureBasic: React.FC = props => { return (
Show More - + Item 1 Item 2 Item 3 diff --git a/src/disclosure/stories/DisclosureBasic.css b/src/disclosure/stories/DisclosureBasic.css index 2dfb84959..8166f139a 100644 --- a/src/disclosure/stories/DisclosureBasic.css +++ b/src/disclosure/stories/DisclosureBasic.css @@ -4,28 +4,17 @@ overflow: hidden; } -.content[data-enter] { - animation: slideDown 300ms ease-out; -} - -.content[data-leave] { - animation: slideUp 300ms ease-in; +.content { + transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; + opacity: 0; + transform: translate3d(0, -100%, 0); } -@keyframes slideDown { - from { - height: 0; - } - to { - height: var(--content-height); - } +.content[data-enter] { + opacity: 1; + transform: translate3d(0, 0, 0); } -@keyframes slideUp { - from { - height: var(--content-height); - } - to { - height: 0; - } +.content[data-leave] { + transform: translate3d(0, 100%, 0); } diff --git a/src/disclosure/stories/DisclosureHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontal.component.tsx index c32b686fd..0b2d42027 100644 --- a/src/disclosure/stories/DisclosureHorizontal.component.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.component.tsx @@ -16,7 +16,7 @@ export const DisclosureHorizontal: React.FC = return (
Show More - +
Item 1
Item 2
Item 3
diff --git a/src/drawer/Drawer.ts b/src/drawer/Drawer.ts index b0a9dd187..e79aa0a50 100644 --- a/src/drawer/Drawer.ts +++ b/src/drawer/Drawer.ts @@ -1,5 +1,6 @@ import { createComponent, createHook } from "reakit-system"; -import { DialogHTMLProps, DialogOptions, useDialog } from "reakit"; + +import { DialogHTMLProps, DialogOptions, useDialog } from "../dialog"; import { DRAWER_KEYS } from "./__keys"; diff --git a/src/drawer/DrawerBackdrop.ts b/src/drawer/DrawerBackdrop.ts new file mode 100644 index 000000000..d406f2fc7 --- /dev/null +++ b/src/drawer/DrawerBackdrop.ts @@ -0,0 +1,35 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + DialogBackdropHTMLProps, + DialogBackdropOptions, + useDialogBackdrop, +} from "../dialog"; + +import { DRAWER_BACKDROP_KEYS } from "./__keys"; + +export type DrawerBackdropOptions = DialogBackdropOptions; + +export type DrawerBackdropHTMLProps = DialogBackdropHTMLProps; + +export type DrawerBackdropProps = DrawerBackdropOptions & + DrawerBackdropHTMLProps; + +export const useDrawerBackdrop = createHook< + DrawerBackdropOptions, + DrawerBackdropHTMLProps +>({ + name: "DrawerBackdrop", + compose: useDialogBackdrop, + keys: DRAWER_BACKDROP_KEYS, + + useOptions({ modal = false, ...options }) { + return { modal, ...options }; + }, +}); + +export const DrawerBackdrop = createComponent({ + as: "div", + memo: true, + useHook: useDrawerBackdrop, +}); diff --git a/src/drawer/DrawerCloseButton.ts b/src/drawer/DrawerCloseButton.ts deleted file mode 100644 index 5cbb95d96..000000000 --- a/src/drawer/DrawerCloseButton.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createComponent, createHook } from "reakit-system"; -import { - DialogDisclosureHTMLProps, - DialogDisclosureOptions, - useDialogDisclosure, -} from "reakit"; - -import { DRAWER_CLOSE_BUTTON_KEYS } from "./__keys"; - -export type DrawerCloseButtonOptions = DialogDisclosureOptions; - -export type DrawerCloseButtonHTMLProps = DialogDisclosureHTMLProps; - -export type DrawerCloseButtonProps = DrawerCloseButtonOptions & - DrawerCloseButtonHTMLProps; - -export const useDrawerCloseButton = createHook< - DrawerCloseButtonOptions, - DrawerCloseButtonHTMLProps ->({ - name: "DrawerCloseButton", - compose: useDialogDisclosure, - keys: DRAWER_CLOSE_BUTTON_KEYS, -}); - -export const DrawerCloseButton = createComponent({ - as: "button", - useHook: useDrawerCloseButton, -}); diff --git a/src/drawer/DrawerDisclosure.tsx b/src/drawer/DrawerDisclosure.tsx new file mode 100644 index 000000000..5cd2bbac4 --- /dev/null +++ b/src/drawer/DrawerDisclosure.tsx @@ -0,0 +1,38 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + DialogDisclosureHTMLProps, + DialogDisclosureOptions, + useDialogDisclosure, +} from "../dialog"; + +import { DRAWER_DISCLOSURE_KEYS } from "./__keys"; + +export type DrawerDisclosureOptions = DialogDisclosureOptions & {}; +export type DrawerDisclosureHTMLProps = DialogDisclosureHTMLProps; + +export type DrawerDisclosureProps = DrawerDisclosureOptions & + DrawerDisclosureHTMLProps; + +export const useDrawerDisclosure = createHook< + DrawerDisclosureOptions, + DrawerDisclosureHTMLProps +>({ + name: "DrawerDisclosure", + compose: useDialogDisclosure, + keys: DRAWER_DISCLOSURE_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const DrawerDisclosure = createComponent({ + as: "button", + memo: true, + useHook: useDrawerDisclosure, +}); diff --git a/src/drawer/DrawerState.ts b/src/drawer/DrawerState.ts new file mode 100644 index 000000000..f27d29e5c --- /dev/null +++ b/src/drawer/DrawerState.ts @@ -0,0 +1,24 @@ +import { + DialogActions, + DialogInitialState, + DialogState, + useDialogState, +} from "../dialog"; + +export type DrawerState = DialogState & {}; + +export type DrawerActions = DialogActions & {}; + +export type DrawerInitialState = DialogInitialState; + +export type DrawerStateReturn = DrawerState & DrawerActions; + +export function useDrawerState( + props: DrawerInitialState = {}, +): DrawerStateReturn { + const dialog = useDialogState(props); + + return { + ...dialog, + }; +} diff --git a/src/drawer/__keys.ts b/src/drawer/__keys.ts index 3dda0fd1b..cef160ba5 100644 --- a/src/drawer/__keys.ts +++ b/src/drawer/__keys.ts @@ -1,3 +1,24 @@ // Automatically generated -export const DRAWER_KEYS = ["placement"] as const; -export const DRAWER_CLOSE_BUTTON_KEYS = [] as const; +export const USE_DRAWER_STATE_KEYS = [ + "baseId", + "visible", + "defaultVisible", + "onVisibleChange", + "modal", +] as const; +export const DRAWER_STATE_KEYS = [ + "baseId", + "unstable_idCountRef", + "visible", + "modal", + "disclosureRef", + "setBaseId", + "show", + "hide", + "toggle", + "setVisible", + "setModal", +] as const; +export const DRAWER_KEYS = [...DRAWER_STATE_KEYS, "placement"] as const; +export const DRAWER_BACKDROP_KEYS = DRAWER_STATE_KEYS; +export const DRAWER_DISCLOSURE_KEYS = DRAWER_BACKDROP_KEYS; diff --git a/src/drawer/index.ts b/src/drawer/index.ts index 8a71fa2fe..2933bf42d 100644 --- a/src/drawer/index.ts +++ b/src/drawer/index.ts @@ -1,10 +1,5 @@ export * from "./__keys"; export * from "./Drawer"; -export * from "./DrawerCloseButton"; -export { - DialogBackdrop as DrawerBackdrop, - DialogDisclosure as DrawerDisclosure, - useDialogBackdrop as useDrawerBackdrop, - useDialogDisclosure as useDrawerDisclosure, - useDialogState as useDrawerState, -} from "reakit/Dialog"; +export * from "./DrawerBackdrop"; +export * from "./DrawerDisclosure"; +export * from "./DrawerState"; diff --git a/src/drawer/stories/DrawerBasic.component.tsx b/src/drawer/stories/DrawerBasic.component.tsx index 8f186a335..a7b05be81 100644 --- a/src/drawer/stories/DrawerBasic.component.tsx +++ b/src/drawer/stories/DrawerBasic.component.tsx @@ -2,23 +2,22 @@ import React from "react"; import { css } from "@emotion/css"; import { - Drawer as RenderlesskitDrawer, + Drawer, DrawerBackdrop, - DrawerCloseButton, DrawerDisclosure, DrawerInitialState, Placement, useDrawerState, } from "../../index"; -export const Drawer: React.FC = props => { - const dialog = useDrawerState({ animated: true, ...props }); +export const DrawerBasic: React.FC = props => { + const drawer = useDrawerState(props); const inputRef = React.useRef(null); const [placement, setPlacement] = React.useState("left"); return (
- {`Open Drawer`} + {`Open Drawer`} - - + = props => { opacity: 1; transform: translate(0, 0); } + &[data-leave] { + opacity: 0; + transform: ${cssTransforms[placement]}; + } `} + transition={true} unstable_initialFocusRef={inputRef} > - X + X

Welcome to Reakit!

-
+
); }; -export default Drawer; +export default DrawerBasic; const backdropStyles = css` opacity: 0; diff --git a/src/drawer/stories/DrawerBasic.stories.tsx b/src/drawer/stories/DrawerBasic.stories.tsx index e0fd22804..8d31126fe 100644 --- a/src/drawer/stories/DrawerBasic.stories.tsx +++ b/src/drawer/stories/DrawerBasic.stories.tsx @@ -5,10 +5,10 @@ import { createPreviewTabs } from "../../../.storybook/utils"; import js from "./templates/DrawerBasicJsx"; import ts from "./templates/DrawerBasicTsx"; -import { Drawer } from "./DrawerBasic.component"; +import { DrawerBasic } from "./DrawerBasic.component"; export default { - component: Drawer, + component: DrawerBasic, title: "Drawer/Basic", parameters: { layout: "centered", @@ -16,4 +16,4 @@ export default { }, } as Meta; -export const Default: Story = args => ; +export const Default: Story = args => ; diff --git a/src/index.ts b/src/index.ts index 1a33fbdfb..46ffd3928 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,4 +21,5 @@ export * from "./slider"; export * from "./system"; export * from "./timepicker"; export * from "./toast"; +export * from "./tooltip"; export * from "./utils"; diff --git a/src/popover/Popover.tsx b/src/popover/Popover.tsx index b3a6cc930..3ae1c1796 100644 --- a/src/popover/Popover.tsx +++ b/src/popover/Popover.tsx @@ -1,3 +1,4 @@ +import { CSSProperties } from "react"; import { createComponent, createHook } from "reakit-system"; import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; @@ -22,7 +23,7 @@ export const usePopover = createHook({ return { style: { - ...popperStyles, + ...(popperStyles as CSSProperties), ...htmlStyle, }, ...restHtmlProps, diff --git a/src/popover/PopoverState.ts b/src/popover/PopoverState.ts index 04f82dd0c..83eedcc64 100644 --- a/src/popover/PopoverState.ts +++ b/src/popover/PopoverState.ts @@ -1,4 +1,7 @@ import * as React from "react"; +import { getPlacementData, PlacementData } from "@radix-ui/popper"; +import { useRect } from "@radix-ui/react-use-rect"; +import { useSize } from "@radix-ui/react-use-size"; import { DialogActions, @@ -7,10 +10,6 @@ import { useDialogState, } from "../dialog"; -import { getPlacementData, PlacementData } from "./popper-core"; -import { useRect } from "./useRect"; -import { useSize } from "./useSize"; - export type PopoverState = DialogState & PlacementData & { side: "top" | "bottom" | "left" | "right"; diff --git a/src/popover/__keys.ts b/src/popover/__keys.ts index aea2dd024..20ce2c07a 100644 --- a/src/popover/__keys.ts +++ b/src/popover/__keys.ts @@ -56,16 +56,3 @@ export const POPOVER_BACKDROP_KEYS = POPOVER_ARROW_CONTENT_KEYS; export const POPOVER_CONTENT_KEYS = POPOVER_BACKDROP_KEYS; export const POPOVER_DISCLOSURE_KEYS = POPOVER_CONTENT_KEYS; export const POPOVER_TRIGGER_KEYS = POPOVER_DISCLOSURE_KEYS; -export const GET_PLACEMENT_DATA_KEYS = [ - ...POPOVER_TRIGGER_KEYS, - "anchorRect", - "popperSize", - "arrowSize", - "shouldAvoidCollisions", - "collisionBoundariesRect", -] as const; -export const GET_ARROW_STYLES_KEYS = [ - ...POPOVER_TRIGGER_KEYS, - "popperSize", - "arrowSize", -] as const; diff --git a/src/popover/index.ts b/src/popover/index.ts index 1e32c2acb..2b5c471fd 100644 --- a/src/popover/index.ts +++ b/src/popover/index.ts @@ -2,8 +2,8 @@ export * from "./Popover"; export * from "./PopoverAnchor"; export * from "./PopoverArrow"; export * from "./PopoverArrowContent"; +export * from "./PopoverBackdrop"; export * from "./PopoverContent"; export * from "./PopoverDisclosure"; export * from "./PopoverState"; export * from "./PopoverTrigger"; -export * from "./popper-core"; diff --git a/src/popover/popper-core.ts b/src/popover/popper-core.ts deleted file mode 100644 index 901c2f7d9..000000000 --- a/src/popover/popper-core.ts +++ /dev/null @@ -1,549 +0,0 @@ -import * as CSS from "csstype"; - -const SIDE_OPTIONS = ["top", "right", "bottom", "left"] as const; -const ALIGN_OPTIONS = ["start", "center", "end"] as const; - -type Axis = "x" | "y"; -type Side = typeof SIDE_OPTIONS[number]; -type Align = typeof ALIGN_OPTIONS[number]; -type Point = { x: number; y: number }; -type Size = { width: number; height: number }; - -type GetPlacementDataOptions = { - /** The rect of the anchor we are placing around */ - anchorRect?: ClientRect; - /** The size of the popper to place */ - popperSize?: Size; - /** An optional arrow size */ - arrowSize?: Size; - /** An optional arrow offset (along the side, default: 0) */ - arrowOffset?: number; - /** The desired side */ - side: Side; - /** An optional side offset (distance from the side, default: 0) */ - sideOffset?: number; - /** The desired alignment */ - align: Align; - /** An optional alignment offset (distance along the side, default: 0) */ - alignOffset?: number; - /** An option to turn on/off the collision handling (default: true) */ - shouldAvoidCollisions?: boolean; - /** The rect which represents the boundaries for collision checks */ - collisionBoundariesRect?: ClientRect; - /** The tolerance used for collisions, ie. if we want them to trigger a bit earlier (default: 0) */ - collisionTolerance?: number; -}; - -export type PlacementData = { - popperStyles: CSS.Properties; - arrowStyles: CSS.Properties; - placedSide?: Side; - placedAlign?: Align; -}; - -/** - * Given all the information necessary to compute it, - * this function calculates all the necessary placement data. - * - * It will return: - * - * - the styles to apply to the popper (including a custom property that is useful to set the transform origin in the right place) - * - the styles to apply to the arrow - * - the placed side (because it might have changed because of collisions) - * - the placed align (because it might have changed because of collisions) - */ -function getPlacementData({ - anchorRect, - popperSize, - arrowSize, - arrowOffset = 0, - side, - sideOffset = 0, - align, - alignOffset = 0, - shouldAvoidCollisions = true, - collisionBoundariesRect, - collisionTolerance = 0, -}: GetPlacementDataOptions): PlacementData { - // if we're not ready to do all the measurements yet, - // we return some good default styles - if (!anchorRect || !popperSize || !collisionBoundariesRect) { - return { - popperStyles: UNMEASURED_POPPER_STYLES, - arrowStyles: UNMEASURED_ARROW_STYLES, - }; - } - - // pre-compute points for all potential placements - const allPlacementPoints = getAllPlacementPoints( - popperSize, - anchorRect, - sideOffset, - alignOffset, - arrowSize, - ); - - // get point based on side / align - const popperPoint = allPlacementPoints[side][align]; - - // if we don't need to avoid collisions, we can stop here - if (shouldAvoidCollisions === false) { - const popperStyles = getPlacementStylesForPoint(popperPoint); - - let arrowStyles = UNMEASURED_ARROW_STYLES; - if (arrowSize) { - arrowStyles = getPopperArrowStyles({ - popperSize, - arrowSize, - arrowOffset, - side, - align, - }); - } - - const transformOrigin = getTransformOrigin( - popperSize, - side, - align, - arrowOffset, - arrowSize, - ); - - return { - popperStyles: { - ...popperStyles, - ["--radix-popper-transform-origin" as any]: transformOrigin, - }, - arrowStyles, - placedSide: side, - placedAlign: align, - }; - } - - // create a new rect as if element had been moved to new placement - const popperRect = DOMRect.fromRect({ ...popperSize, ...popperPoint }); - - // create a new rect representing the collision boundaries but taking into account any added tolerance - const collisionBoundariesRectWithTolerance = getContractedRect( - collisionBoundariesRect, - collisionTolerance, - ); - - // check for any collisions in new placement - const popperCollisions = getCollisions( - popperRect, - collisionBoundariesRectWithTolerance, - ); - - // do all the same calculations for the opposite side - // this is because we need to check for potential collisions if we were to swap side - const oppositeSide = getOppositeSide(side); - const oppositeSidePopperPoint = allPlacementPoints[oppositeSide][align]; - const updatedOppositeSidePopperPoint = DOMRect.fromRect({ - ...popperSize, - ...oppositeSidePopperPoint, - }); - const oppositeSidePopperCollisions = getCollisions( - updatedOppositeSidePopperPoint, - collisionBoundariesRectWithTolerance, - ); - - // adjust side accounting for collisions / opposite side collisions - const placedSide = getSideAccountingForCollisions( - side, - popperCollisions, - oppositeSidePopperCollisions, - ); - - // adjust alignnment accounting for collisions - const placedAlign = getAlignAccountingForCollisions( - popperSize, - anchorRect, - side, - align, - popperCollisions, - ); - - const placedPopperPoint = allPlacementPoints[placedSide][placedAlign]; - - // compute adjusted popper / arrow styles - const popperStyles = getPlacementStylesForPoint(placedPopperPoint); - - let arrowStyles = UNMEASURED_ARROW_STYLES; - if (arrowSize) { - arrowStyles = getPopperArrowStyles({ - popperSize, - arrowSize, - arrowOffset, - side: placedSide, - align: placedAlign, - }); - } - - const transformOrigin = getTransformOrigin( - popperSize, - placedSide, - placedAlign, - arrowOffset, - arrowSize, - ); - - return { - popperStyles: { - ...popperStyles, - ["--radix-popper-transform-origin" as any]: transformOrigin, - }, - arrowStyles, - placedSide, - placedAlign, - }; -} - -type AllPlacementPoints = Record>; - -function getAllPlacementPoints( - popperSize: Size, - anchorRect: ClientRect, - sideOffset: number = 0, - alignOffset: number = 0, - arrowSize?: Size, -): AllPlacementPoints { - const arrowBaseToTipLength = arrowSize ? arrowSize.height : 0; - - const x = getPopperSlotsForAxis(anchorRect, popperSize, "x"); - const y = getPopperSlotsForAxis(anchorRect, popperSize, "y"); - - const topY = y.before - sideOffset - arrowBaseToTipLength; // prettier-ignore - const bottomY = y.after + sideOffset + arrowBaseToTipLength; // prettier-ignore - const leftX = x.before - sideOffset - arrowBaseToTipLength; // prettier-ignore - const rightX = x.after + sideOffset + arrowBaseToTipLength; // prettier-ignore - - // prettier-ignore - const map: AllPlacementPoints = { - top: { - start: { x: x.start + alignOffset, y: topY }, - center: { x: x.center, y: topY }, - end: { x: x.end - alignOffset, y: topY }, - }, - right: { - start: { x: rightX, y: y.start + alignOffset }, - center: { x: rightX, y: y.center }, - end: { x: rightX, y: y.end - alignOffset }, - }, - bottom: { - start: { x: x.start + alignOffset, y: bottomY }, - center: { x: x.center, y: bottomY }, - end: { x: x.end - alignOffset, y: bottomY }, - }, - left: { - start: { x: leftX, y: y.start + alignOffset }, - center: { x: leftX, y: y.center }, - end: { x: leftX, y: y.end - alignOffset }, - }, - }; - - return map; -} - -function getPopperSlotsForAxis( - anchorRect: ClientRect, - popperSize: Size, - axis: Axis, -) { - const startSide = axis === "x" ? "left" : "top"; - const anchorStart = anchorRect[startSide]; - - const dimension = axis === "x" ? "width" : "height"; - const anchorDimension = anchorRect[dimension]; - const popperDimension = popperSize[dimension]; - - // prettier-ignore - return { - before: anchorStart - popperDimension, - start: anchorStart, - center: anchorStart + (anchorDimension - popperDimension) / 2, - end: anchorStart + anchorDimension - popperDimension, - after: anchorStart + anchorDimension, - }; -} - -/** - * Gets an adjusted side based on collision information - */ -function getSideAccountingForCollisions( - /** The side we want to ideally position to */ - side: Side, - /** The collisions for this given side */ - collisions: Collisions, - /** The collisions for the opposite side (if we were to swap side) */ - oppositeSideCollisions: Collisions, -): Side { - const oppositeSide = getOppositeSide(side); - // in order to prevent premature jumps - // we only swap side if there's enough space to fit on the opposite side - return collisions[side] && !oppositeSideCollisions[oppositeSide] - ? oppositeSide - : side; -} - -/** - * Gets an adjusted alignment based on collision information - */ -function getAlignAccountingForCollisions( - /** The size of the popper to place */ - popperSize: Size, - /** The size of the anchor we are placing around */ - anchorSize: Size, - /** The final side */ - side: Side, - /** The desired align */ - align: Align, - /** The collisions */ - collisions: Collisions, -): Align { - const isHorizontalSide = side === "top" || side === "bottom"; - const startBound = isHorizontalSide ? "left" : "top"; - const endBound = isHorizontalSide ? "right" : "bottom"; - const dimension = isHorizontalSide ? "width" : "height"; - const isAnchorBigger = anchorSize[dimension] > popperSize[dimension]; - - if (align === "start" || align === "center") { - if ( - (collisions[startBound] && isAnchorBigger) || - (collisions[endBound] && !isAnchorBigger) - ) { - return "end"; - } - } - - if (align === "end" || align === "center") { - if ( - (collisions[endBound] && isAnchorBigger) || - (collisions[startBound] && !isAnchorBigger) - ) { - return "start"; - } - } - - return align; -} - -function getPlacementStylesForPoint(point: Point): CSS.Properties { - const x = Math.round(point.x + window.scrollX); - const y = Math.round(point.y + window.scrollY); - return { - position: "absolute", - top: 0, - left: 0, - minWidth: "max-content", - willChange: "transform", - transform: `translate3d(${x}px, ${y}px, 0)`, - }; -} - -function getTransformOrigin( - popperSize: Size, - side: Side, - align: Align, - arrowOffset: number, - arrowSize?: Size, -): CSS.Properties["transformOrigin"] { - const isHorizontalSide = side === "top" || side === "bottom"; - - const arrowBaseLength = arrowSize ? arrowSize.width : 0; - const arrowBaseToTipLength = arrowSize ? arrowSize.height : 0; - const sideOffset = arrowBaseToTipLength; - const alignOffset = arrowBaseLength / 2 + arrowOffset; - - let x = ""; - let y = ""; - - if (isHorizontalSide) { - x = { - start: `${alignOffset}px`, - center: "center", - end: `${popperSize.width - alignOffset}px`, - }[align]; - - y = - side === "top" - ? `${popperSize.height + sideOffset}px` - : `${-sideOffset}px`; - } else { - x = - side === "left" - ? `${popperSize.width + sideOffset}px` - : `${-sideOffset}px`; - - y = { - start: `${alignOffset}px`, - center: "center", - end: `${popperSize.height - alignOffset}px`, - }[align]; - } - - return `${x} ${y}`; -} - -const UNMEASURED_POPPER_STYLES: CSS.Properties = { - // position: 'fixed' here is important because it will take the popper - // out of the flow so it does not disturb the position of the anchor - position: "fixed", - top: 0, - left: 0, - opacity: 0, - transform: "translate3d(0, -200%, 0)", -}; - -const UNMEASURED_ARROW_STYLES: CSS.Properties = { - // given the arrow is nested inside the popper, - // make sure that it is out of the flow and doesn't hinder then popper's measurement - position: "absolute", - opacity: 0, -}; - -type GetArrowStylesOptions = { - /** The size of the popper to place */ - popperSize: Size; - /** The size of the arrow itself */ - arrowSize: Size; - /** An offset for the arrow along the align axis */ - arrowOffset: number; - /** The side where the arrow points to */ - side: Side; - /** The alignment of the arrow along the side */ - align: Align; -}; - -/** - * Computes the styles necessary to position, rotate and align the arrow correctly. - * It can adjust itself based on anchor/popper size, side/align and an optional offset. - */ -function getPopperArrowStyles({ - popperSize, - arrowSize, - arrowOffset, - side, - align, -}: GetArrowStylesOptions): CSS.Properties { - const popperCenterX = (popperSize.width - arrowSize.width) / 2; - const popperCenterY = (popperSize.height - arrowSize.width) / 2; - - const rotationMap = { top: 0, right: 90, bottom: 180, left: -90 }; - const rotation = rotationMap[side]; - const arrowMaxDimension = Math.max(arrowSize.width, arrowSize.height); - - const styles: CSS.Properties = { - // we make sure we put the arrow inside a 1:1 ratio container - // this is to make the rotation handling simpler - // as we do no need to worry about changing the transform-origin - width: `${arrowMaxDimension}px`, - height: `${arrowMaxDimension}px`, - - // rotate the arrow appropriately - transform: `rotate(${rotation}deg)`, - willChange: "transform", - - // position the arrow appropriately - position: "absolute", - [side]: "100%", - - // Because the arrow gets rotated (see `transform above`) - // and we are putting it inside a 1:1 ratio container - // we need to adjust the CSS direction from `ltr` to `rtl` - // in some circumstances - direction: getArrowCssDirection(side, align), - }; - - if (side === "top" || side === "bottom") { - if (align === "start") { - styles.left = `${arrowOffset}px`; - } - if (align === "center") { - styles.left = `${popperCenterX}px`; - } - if (align === "end") { - styles.right = `${arrowOffset}px`; - } - } - - if (side === "left" || side === "right") { - if (align === "start") { - styles.top = `${arrowOffset}px`; - } - if (align === "center") { - styles.top = `${popperCenterY}px`; - } - if (align === "end") { - styles.bottom = `${arrowOffset}px`; - } - } - - return styles; -} - -/** - * Adjusts the arrow's CSS direction (`ltr` / `rtl`) - */ -function getArrowCssDirection( - side: Side, - align: Align, -): CSS.Property.Direction { - if ((side === "top" || side === "right") && align === "end") { - return "rtl"; - } - - if ((side === "bottom" || side === "left") && align !== "end") { - return "rtl"; - } - - return "ltr"; -} - -/** - * Gets the opposite side of a given side (ie. top => bottom, left => right, …) - */ -function getOppositeSide(side: Side): Side { - const oppositeSides: Record = { - top: "bottom", - right: "left", - bottom: "top", - left: "right", - }; - return oppositeSides[side]; -} - -/** - * Creates a new rect (`ClientRect`) based on a given one but contracted by - * a given amout on each side. - */ -function getContractedRect(rect: ClientRect, amount: number) { - return DOMRect.fromRect({ - width: rect.width - amount * 2, - height: rect.height - amount * 2, - x: rect.left + amount, - y: rect.top + amount, - }); -} - -/** - * Gets collisions for each side of a rect (top, right, bottom, left) - */ -function getCollisions( - /** The rect to test collisions against */ - rect: ClientRect, - /** The rect which represents the boundaries for collision checks */ - collisionBoundariesRect: ClientRect, -) { - return { - top: rect.top < collisionBoundariesRect.top, - right: rect.right > collisionBoundariesRect.right, - bottom: rect.bottom > collisionBoundariesRect.bottom, - left: rect.left < collisionBoundariesRect.left, - }; -} - -type Collisions = ReturnType; - -export { ALIGN_OPTIONS, getPlacementData, SIDE_OPTIONS }; -export type { Align, Side }; diff --git a/src/popover/rect.ts b/src/popover/rect.ts deleted file mode 100644 index 6d40bc21f..000000000 --- a/src/popover/rect.ts +++ /dev/null @@ -1,109 +0,0 @@ -type Measurable = { getBoundingClientRect(): ClientRect }; - -/** - * Observes an element's rectangle on screen (getBoundingClientRect) - * This is useful to track elements on the screen and attach other elements - * that might be in different layers, etc. - */ -function observeElementRect( - /** The element whose rect to observe */ - elementToObserve: Measurable, - /** The callback which will be called when the rect changes */ - callback: CallbackFn, -) { - const observedData = observedElements.get(elementToObserve); - - if (observedData === undefined) { - // add the element to the map of observed elements with its first callback - // because this is the first time this element is observed - observedElements.set(elementToObserve, { - rect: {} as ClientRect, - callbacks: [callback], - }); - - if (observedElements.size === 1) { - // start the internal loop once at least 1 element is observed - rafId = requestAnimationFrame(runLoop); - } - } else { - // only add a callback for this element as it's already observed - observedData.callbacks.push(callback); - callback(elementToObserve.getBoundingClientRect()); - } - - return () => { - const observedData = observedElements.get(elementToObserve); - if (observedData === undefined) return; - - // start by removing the callback - const index = observedData.callbacks.indexOf(callback); - if (index > -1) { - observedData.callbacks.splice(index, 1); - } - - if (observedData.callbacks.length === 0) { - // stop observing this element because there are no - // callbacks registered for it anymore - observedElements.delete(elementToObserve); - - if (observedElements.size === 0) { - // stop the internal loop once no elements are observed anymore - cancelAnimationFrame(rafId); - } - } - }; -} - -// ======================================================================== -// module internals - -type CallbackFn = (rect: ClientRect) => void; - -type ObservedData = { - rect: ClientRect; - callbacks: Array; -}; - -let rafId: number; -const observedElements: Map = new Map(); - -function runLoop() { - const changedRectsData: Array = []; - - // process all DOM reads first (getBoundingClientRect) - observedElements.forEach((data, element) => { - const newRect = element.getBoundingClientRect(); - - // gather all the data for elements whose rects have changed - if (!rectEquals(data.rect, newRect)) { - data.rect = newRect; - changedRectsData.push(data); - } - }); - - // group DOM writes here after the DOM reads (getBoundingClientRect) - // as DOM writes will most likely happen with the callbacks - changedRectsData.forEach(data => { - data.callbacks.forEach(callback => callback(data.rect)); - }); - - rafId = requestAnimationFrame(runLoop); -} -// ======================================================================== - -/** - * Returns whether 2 rects are equal in values - */ -function rectEquals(rect1: ClientRect, rect2: ClientRect) { - return ( - rect1.width === rect2.width && - rect1.height === rect2.height && - rect1.top === rect2.top && - rect1.right === rect2.right && - rect1.bottom === rect2.bottom && - rect1.left === rect2.left - ); -} - -export { observeElementRect }; -export type { Measurable }; diff --git a/src/popover/stories/PopoverBasic.component.tsx b/src/popover/stories/PopoverBasic.component.tsx index fc1956a62..6cbf228b5 100644 --- a/src/popover/stories/PopoverBasic.component.tsx +++ b/src/popover/stories/PopoverBasic.component.tsx @@ -1,12 +1,15 @@ import * as React from "react"; -import { Popover } from "../Popover"; -import { PopoverAnchor } from "../PopoverAnchor"; -import { PopoverArrow } from "../PopoverArrow"; -import { PopoverArrowContent } from "../PopoverArrowContent"; -import { PopoverContent } from "../PopoverContent"; -import { PopoverInitialState, usePopoverState } from "../PopoverState"; -import { PopoverTrigger } from "../PopoverTrigger"; +import { + Popover, + PopoverAnchor, + PopoverArrow, + PopoverArrowContent, + PopoverContent, + PopoverInitialState, + PopoverTrigger, + usePopoverState, +} from "../../index"; export type PopoverBasicProps = PopoverInitialState & {}; @@ -39,7 +42,7 @@ export const PopoverBasic: React.FC = props => { - +
diff --git a/src/popover/stories/PopoverBasic.stories.tsx b/src/popover/stories/PopoverBasic.stories.tsx index f3c0bd98c..b268037cc 100644 --- a/src/popover/stories/PopoverBasic.stories.tsx +++ b/src/popover/stories/PopoverBasic.stories.tsx @@ -3,6 +3,7 @@ import { Meta, Story } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; +import css from "./templates/PopoverBasicCss"; import js from "./templates/PopoverBasicJsx"; import ts from "./templates/PopoverBasicTsx"; import { PopoverBasic } from "./PopoverBasic.component"; @@ -14,7 +15,7 @@ export default { title: "Popover/Basic", parameters: { options: { showPanel: true }, - preview: createPreviewTabs({ js, ts }), + preview: createPreviewTabs({ js, ts, css }), }, } as Meta; diff --git a/src/popover/stories/PopoverCollision.component.tsx b/src/popover/stories/PopoverCollision.component.tsx index d6c1f81da..3a9ca698b 100644 --- a/src/popover/stories/PopoverCollision.component.tsx +++ b/src/popover/stories/PopoverCollision.component.tsx @@ -1,12 +1,15 @@ import * as React from "react"; +import { ALIGN_OPTIONS, SIDE_OPTIONS } from "@radix-ui/popper"; -import { Popover } from "../Popover"; -import { PopoverArrow } from "../PopoverArrow"; -import { PopoverArrowContent } from "../PopoverArrowContent"; -import { PopoverContent } from "../PopoverContent"; -import { PopoverDisclosure } from "../PopoverDisclosure"; -import { PopoverState, usePopoverState } from "../PopoverState"; -import { ALIGN_OPTIONS, SIDE_OPTIONS } from "../popper-core"; +import { + Popover, + PopoverArrow, + PopoverArrowContent, + PopoverContent, + PopoverDisclosure, + PopoverState, + usePopoverState, +} from "../../index"; export const PopoverCollision = () => { return ( diff --git a/src/popover/useRect.ts b/src/popover/useRect.ts deleted file mode 100644 index 05b2e0d5d..000000000 --- a/src/popover/useRect.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from "react"; - -import { Measurable, observeElementRect } from "./rect"; - -/** - * Use this custom hook to get access to an element's rect (getBoundingClientRect) - * and observe it along time. - */ -function useRect(measurable: Measurable | null) { - const [rect, setRect] = React.useState(); - - React.useEffect(() => { - if (measurable) { - const unobserve = observeElementRect(measurable, setRect); - - return () => { - setRect(undefined); - unobserve(); - }; - } - return; - }, [measurable]); - - return rect; -} - -export { useRect }; diff --git a/src/popover/useSize.ts b/src/popover/useSize.ts deleted file mode 100644 index 0ee346f18..000000000 --- a/src/popover/useSize.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from "react"; - -function useSize(element: HTMLElement | SVGElement | null) { - const [size, setSize] = React.useState< - { width: number; height: number } | undefined - >(undefined); - - React.useEffect(() => { - if (element) { - const resizeObserver = new ResizeObserver(entries => { - if (!Array.isArray(entries)) { - return; - } - - // Since we only observe the one element, we don't need to loop over the - // array - if (!entries.length) { - return; - } - - const entry = entries[0]; - let width: number; - let height: number; - - if ("borderBoxSize" in entry) { - const borderSizeEntry = entry["borderBoxSize"]; - // iron out differences between browsers - const borderSize = Array.isArray(borderSizeEntry) - ? borderSizeEntry[0] - : borderSizeEntry; - - width = borderSize["inlineSize"]; - height = borderSize["blockSize"]; - } else { - // for browsers that don't support `borderBoxSize` - // we calculate a rect ourselves to get the correct border box. - const rect = element.getBoundingClientRect(); - width = rect.width; - height = rect.height; - } - - setSize({ width, height }); - }); - - resizeObserver.observe(element, { box: "border-box" }); - - return () => { - setSize(undefined); - - resizeObserver.unobserve(element); - }; - } - - return; - }, [element]); - - return size; -} - -export { useSize }; diff --git a/src/tooltip/stories/TooltipBasic.component.tsx b/src/tooltip/stories/TooltipBasic.component.tsx index 0f7e0b572..9ddd738d4 100644 --- a/src/tooltip/stories/TooltipBasic.component.tsx +++ b/src/tooltip/stories/TooltipBasic.component.tsx @@ -1,9 +1,12 @@ import * as React from "react"; -import { Tooltip } from "../Tooltip"; -import { TooltipContent } from "../TooltipContent"; -import { TooltipReference } from "../TooltipReference"; -import { TooltipInitialState, useTooltipState } from "../TooltipState"; +import { + Tooltip, + TooltipContent, + TooltipInitialState, + TooltipReference, + useTooltipState, +} from "../../index"; export type TooltipBasicProps = TooltipInitialState & {}; @@ -24,7 +27,7 @@ export const TooltipBasic: React.FC = props => { - +
Tooltip
diff --git a/src/tooltip/stories/TooltipCustomAnchor.component.tsx b/src/tooltip/stories/TooltipCustomAnchor.component.tsx index ee2e48484..b7f3e70b2 100644 --- a/src/tooltip/stories/TooltipCustomAnchor.component.tsx +++ b/src/tooltip/stories/TooltipCustomAnchor.component.tsx @@ -1,12 +1,15 @@ import * as React from "react"; -import { Tooltip } from "../Tooltip"; -import { TooltipAnchor } from "../TooltipAnchor"; -import { TooltipArrow } from "../TooltipArrow"; -import { TooltipArrowContent } from "../TooltipArrowContent"; -import { TooltipContent } from "../TooltipContent"; -import { TooltipInitialState, useTooltipState } from "../TooltipState"; -import { TooltipTrigger } from "../TooltipTrigger"; +import { + Tooltip, + TooltipAnchor, + TooltipArrow, + TooltipArrowContent, + TooltipContent, + TooltipInitialState, + TooltipTrigger, + useTooltipState, +} from "../../index"; export type TooltipCustomAnchorProps = TooltipInitialState & {}; @@ -33,7 +36,7 @@ export const TooltipCustomAnchor: React.FC = - +
Tooltip Content
diff --git a/src/tooltip/stories/TooltipCustomContent.component.tsx b/src/tooltip/stories/TooltipCustomContent.component.tsx index d8a7d0a9d..a2fefb009 100644 --- a/src/tooltip/stories/TooltipCustomContent.component.tsx +++ b/src/tooltip/stories/TooltipCustomContent.component.tsx @@ -1,11 +1,14 @@ import * as React from "react"; -import { Tooltip } from "../Tooltip"; -import { TooltipArrow } from "../TooltipArrow"; -import { TooltipArrowContent } from "../TooltipArrowContent"; -import { TooltipContent } from "../TooltipContent"; -import { TooltipReference } from "../TooltipReference"; -import { TooltipInitialState, useTooltipState } from "../TooltipState"; +import { + Tooltip, + TooltipArrow, + TooltipArrowContent, + TooltipContent, + TooltipInitialState, + TooltipReference, + useTooltipState, +} from "../../index"; export type TooltipCustomContentProps = {}; @@ -118,7 +121,7 @@ export const TooltipBasic: React.FC = props => { - + {children} diff --git a/src/tooltip/stories/TooltipCustomDuration.component.tsx b/src/tooltip/stories/TooltipCustomDuration.component.tsx index 810de9517..1c3e5c436 100644 --- a/src/tooltip/stories/TooltipCustomDuration.component.tsx +++ b/src/tooltip/stories/TooltipCustomDuration.component.tsx @@ -1,11 +1,14 @@ import * as React from "react"; -import { Tooltip } from "../Tooltip"; -import { TooltipArrow } from "../TooltipArrow"; -import { TooltipArrowContent } from "../TooltipArrowContent"; -import { TooltipContent } from "../TooltipContent"; -import { TooltipReference } from "../TooltipReference"; -import { TooltipInitialState, useTooltipState } from "../TooltipState"; +import { + Tooltip, + TooltipArrow, + TooltipArrowContent, + TooltipContent, + TooltipInitialState, + TooltipReference, + useTooltipState, +} from "../../index"; export type TooltipCustomDurationProps = {}; @@ -50,7 +53,7 @@ export const TooltipBasic: React.FC = props => { - + TooltipContent diff --git a/src/tooltip/stories/TooltipPositions.component.tsx b/src/tooltip/stories/TooltipPositions.component.tsx index e6591f0f1..412dc1dba 100644 --- a/src/tooltip/stories/TooltipPositions.component.tsx +++ b/src/tooltip/stories/TooltipPositions.component.tsx @@ -1,11 +1,14 @@ import * as React from "react"; -import { Tooltip } from "../Tooltip"; -import { TooltipArrow } from "../TooltipArrow"; -import { TooltipArrowContent } from "../TooltipArrowContent"; -import { TooltipContent } from "../TooltipContent"; -import { TooltipReference } from "../TooltipReference"; -import { TooltipInitialState, useTooltipState } from "../TooltipState"; +import { + Tooltip, + TooltipArrow, + TooltipArrowContent, + TooltipContent, + TooltipInitialState, + TooltipReference, + useTooltipState, +} from "../../index"; export type TooltipPositionsProps = TooltipInitialState & {}; @@ -133,7 +136,7 @@ export const TooltipBasic: React.FC = props => { - +
Tooltip
diff --git a/yarn.lock b/yarn.lock index d00a4f7ac..46bacc0e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3421,6 +3421,59 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353" integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q== +"@radix-ui/popper@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/popper/-/popper-0.1.0.tgz#c387a38f31b7799e1ea0d2bb1ca0c91c2931b063" + integrity sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ== + dependencies: + "@babel/runtime" "^7.13.10" + csstype "^3.0.4" + +"@radix-ui/react-compose-refs@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz#cff6e780a0f73778b976acff2c2a5b6551caab95" + integrity sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-presence@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-0.1.1.tgz#2088dec6f4f8042f83dd2d6bf9e8ef09dadbbc15" + integrity sha512-LsL+NcWDpFUAYCmXeH02o4pgqcSLpwxP84UIjCtpIKrsPe2vLuhcp79KC/jZJeXz+of2lUpMAxpM+eCpxFZtlg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "0.1.0" + "@radix-ui/react-use-layout-effect" "0.1.0" + +"@radix-ui/react-use-layout-effect@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz#ebf71bd6d2825de8f1fbb984abf2293823f0f223" + integrity sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-use-rect@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz#6c15384beee59c086e75b89a7e66f3d2e583a856" + integrity sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/rect" "0.1.1" + +"@radix-ui/react-use-size@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-0.1.0.tgz#dc49295d646f5d3f570943dbb88bd94fc7db7daf" + integrity sha512-TcZAsR+BYI46w/RbaSFCRACl+Jh6mDqhu6GS2r0iuJpIVrj8atff7qtTjmMmfGtEDNEjhl7DxN3pr1nTS/oruQ== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/rect@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-0.1.1.tgz#95b5ba51f469bea6b1b841e2d427e17e37d38419" + integrity sha512-g3hnE/UcOg7REdewduRPAK88EPuLZtaq7sA9ouu8S+YEtnyFRI16jgv6GZYe3VMoQLL1T171ebmEPtDjyxWLzw== + dependencies: + "@babel/runtime" "^7.13.10" + "@reach/observe-rect@^1.1.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2" @@ -8741,6 +8794,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== +csstype@^3.0.4: + version "3.0.10" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.10.tgz#2ad3a7bed70f35b965707c092e5f30b327c290e5" + integrity sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" From c477a10ab1538acd7b5e98d6cbbeee2429b37fc5 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Sat, 20 Nov 2021 00:11:36 +0530 Subject: [PATCH 11/13] =?UTF-8?q?refactor(popover):=20=F0=9F=8F=B7?= =?UTF-8?q?=EF=B8=8F=20udpate=20types=20for=20popover=20arrow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/popover/PopoverArrow.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/popover/PopoverArrow.tsx b/src/popover/PopoverArrow.tsx index ada690c66..9512b0e48 100644 --- a/src/popover/PopoverArrow.tsx +++ b/src/popover/PopoverArrow.tsx @@ -1,3 +1,4 @@ +import { CSSProperties } from "react"; import { createComponent, createHook } from "reakit-system"; import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; @@ -29,7 +30,7 @@ export const usePopoverArrow = createHook< return { style: { - ...arrowStyles, + ...(arrowStyles as CSSProperties), pointerEvents: "none", ...htmlStyle, }, From fd846f6ade6721142173fa6e3cb3369b08478272 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Sat, 20 Nov 2021 14:05:02 +0530 Subject: [PATCH 12/13] =?UTF-8?q?refactor(animation):=20=E2=99=BB=EF=B8=8F?= =?UTF-8?q?=20organize=20&=20fix=20dialog=20present=20bug=20with=20transit?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dialog/stories/DialogBasic.component.tsx | 4 +- src/dialog/stories/DialogBasic.css | 29 +-- src/disclosure/DisclosureContent.tsx | 202 +++++++----------- src/disclosure/__keys.ts | 8 +- src/disclosure/helpers.ts | 131 ++++++++++++ src/drawer/Drawer.ts | 79 ++++--- src/presence/Presence.ts | 44 ---- src/presence/PresenceChildren.tsx | 32 --- src/presence/__keys.ts | 7 - src/presence/index.ts | 4 - .../stories/PresenceAnimated.component.tsx | 27 --- src/presence/stories/PresenceAnimated.css | 25 --- .../stories/PresenceAnimated.stories.tsx | 28 --- .../stories/PresenceBasic.component.tsx | 19 -- .../stories/PresenceBasic.stories.tsx | 22 -- .../stories/TooltipBasic.component.tsx | 2 +- src/tooltip/stories/TooltipBasic.css | 39 +--- src/utils/index.ts | 1 + .../useAnimationPresence}/helpers.tsx | 0 src/utils/useAnimationPresence/index.ts | 2 + .../useAnimationPresence.tsx} | 22 +- 21 files changed, 294 insertions(+), 433 deletions(-) create mode 100644 src/disclosure/helpers.ts delete mode 100644 src/presence/Presence.ts delete mode 100644 src/presence/PresenceChildren.tsx delete mode 100644 src/presence/__keys.ts delete mode 100644 src/presence/index.ts delete mode 100644 src/presence/stories/PresenceAnimated.component.tsx delete mode 100644 src/presence/stories/PresenceAnimated.css delete mode 100644 src/presence/stories/PresenceAnimated.stories.tsx delete mode 100644 src/presence/stories/PresenceBasic.component.tsx delete mode 100644 src/presence/stories/PresenceBasic.stories.tsx rename src/{presence => utils/useAnimationPresence}/helpers.tsx (100%) create mode 100644 src/utils/useAnimationPresence/index.ts rename src/{presence/PresenceState.tsx => utils/useAnimationPresence/useAnimationPresence.tsx} (89%) diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx index 8f377de17..dbd14a5f2 100644 --- a/src/dialog/stories/DialogBasic.component.tsx +++ b/src/dialog/stories/DialogBasic.component.tsx @@ -11,7 +11,7 @@ import { export type DialogBasicProps = DialogInitialState & {}; export const DialogBasic: React.FC = props => { - const dialog = useDialogState({ modal: false }); + const dialog = useDialogState(props); const searchFieldRef = React.useRef(null); const firstNameRef = React.useRef(null); @@ -23,7 +23,7 @@ export const DialogBasic: React.FC = props => { {...dialog} aria-label="Welcome" className="dialog" - animation={true} + transition={true} > Welcome to Reakit!
diff --git a/src/dialog/stories/DialogBasic.css b/src/dialog/stories/DialogBasic.css index 6bd24ef11..9f4d362f2 100644 --- a/src/dialog/stories/DialogBasic.css +++ b/src/dialog/stories/DialogBasic.css @@ -54,30 +54,15 @@ box-shadow: rgb(0 109 255 / 50%) 0px 0px 0px 0.2em; } -.dialog[data-enter] { - animation: slideIn 250ms ease-in-out; -} - -.dialog[data-leave] { - animation: slideOut 250ms ease-in-out; +.dialog { + transition: transform 250ms ease-in-out; + transform: translate(0, -10px); } -@keyframes slideIn { - from { - transform: translate(0, -10px); - } - - to { - transform: translate(0, 0px); - } +.dialog[data-enter] { + transform: translate(0, 0); } -@keyframes slideOut { - from { - transform: translate(0, 0px); - } - - to { - transform: translate(0, -10px); - } +.dialog[data-leave] { + transform: translate(0, -10px); } diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index 944cd1683..64c2602ba 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -2,29 +2,47 @@ import * as React from "react"; import { createComponent } from "reakit-system"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { isSelfTarget, useForkRef, useLiveRef } from "reakit-utils"; -import { useSafeLayoutEffect } from "@chakra-ui/hooks"; +import { useForkRef, useLiveRef } from "reakit-utils"; -import { usePresenceState } from "../presence"; import { createComposableHook } from "../system"; +import { useAnimationPresence } from "../utils"; import { DISCLOSURE_CONTENT_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; +import { + TransitionState, + useAnimationPresenceSize, + UseAnimationPresenceSizeReturnType, + useTransitionPresence, + UseTransitionPresenceReturnType, +} from "./helpers"; export type DisclosureContentOptions = BoxOptions & Pick & { - present: boolean; - presenceRef: ((value: any) => void) | null; - /** * Whether it uses animation or not. */ - animation: boolean; + animation?: boolean; /** * Whether it uses animation or not. */ - transition: boolean; + transition?: boolean; + + /** + * Whether the content is hidden or not. + */ + isHidden?: boolean; + + /** + * Ref for the animation/transition. + */ + presenceRef?: ((value: any) => void) | null; + present?: UseAnimationPresenceSizeReturnType["isPresent"]; + transitionState?: TransitionState; + onEnd?: UseTransitionPresenceReturnType["onEnd"]; + contentWidth?: UseAnimationPresenceSizeReturnType["width"]; + contentHeight?: UseAnimationPresenceSizeReturnType["height"]; }; export type DisclosureContentHTMLProps = BoxHTMLProps; @@ -32,8 +50,6 @@ export type DisclosureContentHTMLProps = BoxHTMLProps; export type DisclosureContentProps = DisclosureContentOptions & DisclosureContentHTMLProps; -type TransitionState = "enter" | "leave" | null; - export const disclosureComposableContent = createComposableHook< DisclosureContentOptions, DisclosureContentHTMLProps @@ -43,106 +59,73 @@ export const disclosureComposableContent = createComposableHook< keys: DISCLOSURE_CONTENT_KEYS, useOptions(options, htmlProps) { - const { visible } = options; - const { ref } = htmlProps; - const { isPresent: present, ref: presenceRef } = usePresenceState({ + const { visible, animation = false, transition = false } = options; + const { isPresent: present, ref: animationRef } = useAnimationPresence({ present: visible, }); + const { + isPresent, + width: contentWidth, + height: contentHeight, + ref: transitionRef, + } = useAnimationPresenceSize({ + present, + visible, + }); + const { transitionState, transitioning, onEnd } = useTransitionPresence({ + transition, + visible, + }); - return { ...options, present, presenceRef: useForkRef(ref, presenceRef) }; + // when opening we want it to immediately open to retrieve dimensions + // when closing we delay `present` to retrieve dimensions before closing + const isVisible = visible || isPresent; + const isHidden = + (animation && !isVisible) || + (transition && !visible && !transitioning) || + (!animation && !transition && !isVisible); + + return { + ...options, + isHidden, + presenceRef: useForkRef(animationRef, transitionRef), + transitionState, + onEnd, + contentWidth, + contentHeight, + present, + }; }, useProps(options, htmlProps) { - const { visible, baseId, presenceRef, present, transition, animation } = - options; + const { + visible, + baseId, + presenceRef, + transition, + animation, + onEnd, + contentWidth: width, + contentHeight: height, + isHidden, + transitionState, + } = options; const { ref: htmlRef, style: htmlStyle, onTransitionEnd: htmlOnTransitionEnd, ...restHtmlProps } = htmlProps; - const ref = React.useRef(null); - const [isPresent, setIsPresent] = React.useState(present); - const heightRef = React.useRef(0); - const height = heightRef.current; - const widthRef = React.useRef(0); - const width = widthRef.current; - - const [transitionState, setTransitionState] = - React.useState(null); - const [transitioning, setTransitioning] = React.useState(false); - const lastVisible = useLastValue(visible); - const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); - - const visibleHasChanged = - lastVisible.current != null && lastVisible.current !== visible; - - if (transition && !transitioning && visibleHasChanged) { - // Sets transitioning to true when when visible is updated - setTransitioning(true); - } - - const raf = React.useRef(0); - - React.useEffect(() => { - if (!transition) return; - // Double RAF is needed so the browser has enough time to paint the - // default styles before processing the `data-enter` attribute. Otherwise - // it wouldn't be considered a transition. - // See https://github.com/reakit/reakit/issues/643 - raf.current = window.requestAnimationFrame(() => { - raf.current = window.requestAnimationFrame(() => { - if (visible) { - if (!transitioning) return; - - setTransitionState("enter"); - } else if (transitioning) { - setTransitionState("leave"); - } else { - setTransitionState(null); - } - }); - }); - - return () => window.cancelAnimationFrame(raf.current); - }, [visible, transitioning, transition]); - - // when opening we want it to immediately open to retrieve dimensions - // when closing we delay `present` to retrieve dimensions before closing - const isVisible = visible || isPresent; - const isHidden = - (animation && !isVisible) || - (transition && !visible && !transitioning) || - (!animation && !transition && !isVisible); + const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); + const onTransitionEnd = React.useCallback( + (event: React.TransitionEvent) => { + onTransitionEndRef.current?.(event); - React.useLayoutEffect(() => { - const node = ref.current; - - if (node) { - const originalTransition = node.style.transition; - const originalAnimation = node.style.animation; - // block any animations/transitions so the element renders at its full dimensions - node.style.transition = "none"; - node.style.animation = "none"; - - // get width and height from full dimensions - const rect = node.getBoundingClientRect(); - heightRef.current = rect.height; - widthRef.current = rect.width; - - // kick off any animations/transitions that were originally set up - node.style.transition = originalTransition; - node.style.animation = originalAnimation; - setIsPresent(present); - } - /** - * depends on `context.open` because it will change to `false` - * when a close is triggered but `present` will be `false` on - * animation end (so when close finishes). This allows us to - * retrieve the dimensions *before* closing. - */ - }, [visible, present]); + onEnd?.(event); + }, + [onEnd, onTransitionEndRef], + ); const style = { "--content-height": height ? `${height}px` : undefined, @@ -151,25 +134,10 @@ export const disclosureComposableContent = createComposableHook< ...htmlStyle, }; - const onTransitionEnd = React.useCallback( - (event: React.TransitionEvent) => { - onTransitionEndRef.current?.(event); - if (!isSelfTarget(event)) return; - if (!transition) return; - if (!transitioning) return; - - // Ignores number animated - setTransitioning(false); - }, - [onTransitionEndRef, transition, transitioning], - ); - return { - ref: useForkRef(presenceRef, useForkRef(ref, htmlRef)), + ref: useForkRef(presenceRef, htmlRef), id: baseId, hidden: isHidden, - // "data-enter": visible ? "" : undefined, - // "data-leave": !visible ? "" : undefined, "data-enter": (transition && transitionState === "enter") || (animation && visible) ? "" @@ -192,13 +160,3 @@ export const DisclosureContent = createComponent({ memo: true, useHook: useDisclosureContent, }); - -function useLastValue(value: T) { - const lastValue = React.useRef(null); - - useSafeLayoutEffect(() => { - lastValue.current = value; - }, [value]); - - return lastValue; -} diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts index 301ab64e3..73ff763e1 100644 --- a/src/disclosure/__keys.ts +++ b/src/disclosure/__keys.ts @@ -18,8 +18,12 @@ export const USE_DISCLOSURE_STATE_KEYS = [ export const DISCLOSURE_KEYS = DISCLOSURE_STATE_KEYS; export const DISCLOSURE_CONTENT_KEYS = [ ...DISCLOSURE_KEYS, - "present", - "presenceRef", "animation", "transition", + "isHidden", + "presenceRef", + "transitionState", + "onEnd", + "contentWidth", + "contentHeight", ] as const; diff --git a/src/disclosure/helpers.ts b/src/disclosure/helpers.ts new file mode 100644 index 000000000..94cbc56f1 --- /dev/null +++ b/src/disclosure/helpers.ts @@ -0,0 +1,131 @@ +import * as React from "react"; +import { isSelfTarget } from "reakit-utils"; +import { useSafeLayoutEffect } from "@chakra-ui/hooks"; + +function useLastValue(value: T) { + const lastValue = React.useRef(null); + + useSafeLayoutEffect(() => { + lastValue.current = value; + }, [value]); + + return lastValue; +} + +export type useAnimationPresenceSizeProps = { + present: boolean; + visible: boolean; +}; + +export const useAnimationPresenceSize = ( + props: useAnimationPresenceSizeProps, +) => { + const { present, visible } = props; + const ref = React.useRef(null); + const [isPresent, setIsPresent] = React.useState(present); + const heightRef = React.useRef(0); + const height = heightRef.current; + const widthRef = React.useRef(0); + const width = widthRef.current; + + React.useLayoutEffect(() => { + const node = ref.current; + + if (node) { + const originalTransition = node.style.transition; + const originalAnimation = node.style.animation; + // block any animations/transitions so the element renders at its full dimensions + node.style.transition = "none"; + node.style.animation = "none"; + + // get width and height from full dimensions + const rect = node.getBoundingClientRect(); + heightRef.current = rect.height; + widthRef.current = rect.width; + + // kick off any animations/transitions that were originally set up + node.style.transition = originalTransition; + node.style.animation = originalAnimation; + setIsPresent(present); + } + /** + * depends on `context.open` because it will change to `false` + * when a close is triggered but `present` will be `false` on + * animation end (so when close finishes). This allows us to + * retrieve the dimensions *before* closing. + */ + }, [visible, present]); + + return { isPresent, height, width, ref }; +}; + +export type UseAnimationPresenceSizeReturnType = ReturnType< + typeof useAnimationPresenceSize +>; + +export type TransitionState = "enter" | "leave" | null; + +export type useTransitionPresenceProps = { + transition: boolean; + visible: boolean; +}; + +export const useTransitionPresence = (props: useTransitionPresenceProps) => { + const { transition, visible } = props; + const [transitionState, setTransitionState] = + React.useState(null); + const [transitioning, setTransitioning] = React.useState(false); + const lastVisible = useLastValue(visible); + + const visibleHasChanged = + lastVisible.current != null && lastVisible.current !== visible; + + if (transition && !transitioning && visibleHasChanged) { + // Sets transitioning to true when when visible is updated + setTransitioning(true); + } + + const raf = React.useRef(0); + + React.useEffect(() => { + if (!transition) return; + + // Double RAF is needed so the browser has enough time to paint the + // default styles before processing the `data-enter` attribute. Otherwise + // it wouldn't be considered a transition. + // See https://github.com/reakit/reakit/issues/643 + raf.current = window.requestAnimationFrame(() => { + raf.current = window.requestAnimationFrame(() => { + if (visible) { + if (!transitioning) return; + + setTransitionState("enter"); + } else if (transitioning) { + setTransitionState("leave"); + } else { + setTransitionState(null); + } + }); + }); + + return () => window.cancelAnimationFrame(raf.current); + }, [visible, transitioning, transition]); + + const onEnd = React.useCallback( + (event: React.SyntheticEvent) => { + if (!isSelfTarget(event)) return; + if (!transition) return; + if (!transitioning) return; + + // Ignores number animated + setTransitioning(false); + }, + [transition, transitioning], + ); + + return { transitionState, transitioning, onEnd }; +}; + +export type UseTransitionPresenceReturnType = ReturnType< + typeof useTransitionPresence +>; diff --git a/src/drawer/Drawer.ts b/src/drawer/Drawer.ts index e79aa0a50..01f088415 100644 --- a/src/drawer/Drawer.ts +++ b/src/drawer/Drawer.ts @@ -4,6 +4,55 @@ import { DialogHTMLProps, DialogOptions, useDialog } from "../dialog"; import { DRAWER_KEYS } from "./__keys"; +export type DrawerOptions = DialogOptions & { + /** + * Direction to place the drawer. + * + * @default left + */ + placement: Placement; +}; + +export type DrawerHTMLProps = DialogHTMLProps; + +export type DrawerProps = DrawerOptions & DrawerHTMLProps; + +export const useDrawer = createHook({ + name: "Drawer", + compose: useDialog, + keys: DRAWER_KEYS, + + useOptions(options, htmlProps) { + const { placement = "left" } = options; + + return { + ...options, + placement, + }; + }, + + useProps(options, htmlProps) { + const { placement } = options; + const { style: htmlStyles, ...restHtmlProps } = htmlProps; + + return { + style: { + ...PLACEMENTS[placement], + position: "fixed", + ...htmlStyles, + }, + ...restHtmlProps, + }; + }, +}); + +export const Drawer = createComponent({ + as: "div", + useHook: useDrawer, +}); + +export type Placement = keyof typeof PLACEMENTS; + const PLACEMENTS = { left: { left: 0, @@ -30,33 +79,3 @@ const PLACEMENTS = { width: "100vw", }, }; - -export type Placement = keyof typeof PLACEMENTS; - -export type DrawerOptions = DialogOptions & { placement?: Placement }; - -export type DrawerHTMLProps = DialogHTMLProps; - -export type DrawerProps = DrawerOptions & DrawerHTMLProps; - -export const useDrawer = createHook({ - name: "Drawer", - compose: useDialog, - keys: DRAWER_KEYS, - - useProps({ placement = "left" }, { style: htmlStyles, ...htmlProps }) { - return { - style: { - ...PLACEMENTS[placement], - position: "fixed", - ...htmlStyles, - }, - ...htmlProps, - }; - }, -}); - -export const Drawer = createComponent({ - as: "div", - useHook: useDrawer, -}); diff --git a/src/presence/Presence.ts b/src/presence/Presence.ts deleted file mode 100644 index 683d9df1d..000000000 --- a/src/presence/Presence.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createComponent, createHook } from "reakit-system"; -import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { useForkRef } from "reakit-utils"; - -import { PRESENCE_KEYS } from "./__keys"; -import { PresenceStateReturn } from "./PresenceState"; - -export type PresenceOptions = BoxOptions & - PresenceStateReturn & { - present?: boolean; - }; - -export type PresenceHTMLProps = BoxHTMLProps & { - present?: boolean; -}; - -export type PresenceProps = PresenceOptions & PresenceHTMLProps; - -export const usePresence = createHook({ - name: "Presence", - compose: useBox, - keys: PRESENCE_KEYS, - - useOptions(options, htmlProps) { - const { present: htmlPresent } = htmlProps; - const { isPresent } = options; - const present = htmlPresent != null ? htmlPresent : isPresent; - - return { ...options, present }; - }, - - useProps(options, htmlProps) { - const { ref } = options; - const { ref: htmlRef, ...restHtmlProps } = htmlProps; - - return { ref: useForkRef(ref, htmlRef), ...restHtmlProps }; - }, -}); - -export const Presence = createComponent({ - as: "div", - memo: true, - useHook: usePresence, -}); diff --git a/src/presence/PresenceChildren.tsx b/src/presence/PresenceChildren.tsx deleted file mode 100644 index 5f5aac43f..000000000 --- a/src/presence/PresenceChildren.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// Inspired from Radix UI Presence - https://github.com/radix-ui/primitives/tree/main/packages/react/presence -import * as React from "react"; -import { useForkRef } from "reakit-utils"; - -import { usePresenceState } from "./PresenceState"; - -export interface PresenceChildrenProps { - present: boolean; - children: - | React.ReactElement - | ((props: { present: boolean }) => React.ReactElement); -} - -export const PresenceChildren: React.FC = props => { - const { present, children } = props; - const presence = usePresenceState({ present }); - - const child = ( - typeof children === "function" - ? children({ present: presence.isPresent }) - : React.Children.only(children) - ) as React.ReactElement; - - const ref = useForkRef(presence.ref, (child as any).ref); - const forceMount = typeof children === "function"; - - return forceMount || presence.isPresent - ? React.cloneElement(child, { ref }) - : null; -}; - -PresenceChildren.displayName = "PresenceChildren"; diff --git a/src/presence/__keys.ts b/src/presence/__keys.ts deleted file mode 100644 index 279b8f0e0..000000000 --- a/src/presence/__keys.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Automatically generated -export const PRESENCE_STATE_KEYS = ["isPresent", "ref"] as const; -export const USE_PRESENCE_STATE_KEYS = ["present"] as const; -export const PRESENCE_KEYS = [ - ...PRESENCE_STATE_KEYS, - ...USE_PRESENCE_STATE_KEYS, -] as const; diff --git a/src/presence/index.ts b/src/presence/index.ts deleted file mode 100644 index 8b18421e6..000000000 --- a/src/presence/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./__keys"; -export * from "./Presence"; -export * from "./PresenceChildren"; -export * from "./PresenceState"; diff --git a/src/presence/stories/PresenceAnimated.component.tsx b/src/presence/stories/PresenceAnimated.component.tsx deleted file mode 100644 index bd9e3fd56..000000000 --- a/src/presence/stories/PresenceAnimated.component.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from "react"; - -import { Presence, usePresenceState } from "../../index"; - -export type PresenceAnimatedProps = {}; - -export const PresenceAnimated = () => { - const [open, setOpen] = React.useState(true); - - const state = usePresenceState({ present: open }); - - return ( - <> - - - {state.isPresent ? ( - - Content - - ) : null} - - ); -}; diff --git a/src/presence/stories/PresenceAnimated.css b/src/presence/stories/PresenceAnimated.css deleted file mode 100644 index 6028f7d4a..000000000 --- a/src/presence/stories/PresenceAnimated.css +++ /dev/null @@ -1,25 +0,0 @@ -.content[data-state="open"] { - animation: fadeIn 3s ease-out; -} - -.content[data-state="closed"] { - animation: fadeOut 3s ease-in; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes fadeOut { - from { - opacity: 1; - } - to { - opacity: 0; - } -} diff --git a/src/presence/stories/PresenceAnimated.stories.tsx b/src/presence/stories/PresenceAnimated.stories.tsx deleted file mode 100644 index 41e9b6863..000000000 --- a/src/presence/stories/PresenceAnimated.stories.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createPreviewTabs } from "../../../.storybook/utils"; - -import css from "./templates/PresenceAnimatedCss"; -import js from "./templates/PresenceAnimatedJsx"; -import ts from "./templates/PresenceAnimatedTsx"; -import { - PresenceAnimated, - PresenceAnimatedProps, -} from "./PresenceAnimated.component"; - -import "./PresenceAnimated.css"; - -export default { - component: PresenceAnimated, - title: "Presence/Animated", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts, css }), - }, -} as Meta; - -export const Default: Story = args => ( - -); -Default.args = {}; diff --git a/src/presence/stories/PresenceBasic.component.tsx b/src/presence/stories/PresenceBasic.component.tsx deleted file mode 100644 index f41676393..000000000 --- a/src/presence/stories/PresenceBasic.component.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react"; - -import { Presence, usePresenceState } from "../../index"; - -export type PresenceBasicProps = {}; - -export const PresenceBasic = () => { - const [open, setOpen] = React.useState(true); - - const state = usePresenceState({ present: open }); - - return ( - <> - - - {state.isPresent ? Content : null} - - ); -}; diff --git a/src/presence/stories/PresenceBasic.stories.tsx b/src/presence/stories/PresenceBasic.stories.tsx deleted file mode 100644 index 0960d5e1c..000000000 --- a/src/presence/stories/PresenceBasic.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createPreviewTabs } from "../../../.storybook/utils"; - -import js from "./templates/PresenceBasicJsx"; -import ts from "./templates/PresenceBasicTsx"; -import { PresenceBasic, PresenceBasicProps } from "./PresenceBasic.component"; - -export default { - component: PresenceBasic, - title: "Presence/Basic", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, -} as Meta; - -export const Default: Story = args => ( - -); -Default.args = {}; diff --git a/src/tooltip/stories/TooltipBasic.component.tsx b/src/tooltip/stories/TooltipBasic.component.tsx index 9ddd738d4..4fa52ee4e 100644 --- a/src/tooltip/stories/TooltipBasic.component.tsx +++ b/src/tooltip/stories/TooltipBasic.component.tsx @@ -27,7 +27,7 @@ export const TooltipBasic: React.FC = props => { - +
Tooltip
diff --git a/src/tooltip/stories/TooltipBasic.css b/src/tooltip/stories/TooltipBasic.css index 919e53a36..ba67e8c95 100644 --- a/src/tooltip/stories/TooltipBasic.css +++ b/src/tooltip/stories/TooltipBasic.css @@ -1,38 +1,15 @@ .content { - background-color: #222; + background-color: rgba(33, 33, 33, 0.9); color: white; - padding: 5px 10px; - border-radius: 5px; -} - -.content { + padding: 8px; + border-radius: 4px; + transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; + opacity: 0; transform-origin: top center; + transform: translate3d(0, -20px, 0); } .content[data-enter] { - animation: fadeIn 200ms ease-in-out; -} - -.content[data-leave] { - animation: fadeOut 200ms ease-in-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes fadeOut { - from { - opacity: 1; - } - - to { - opacity: 0; - } + opacity: 1; + transform: translate3d(0, 0, 0); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 1f22853ad..4354f2971 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -86,4 +86,5 @@ export function splitStateProps(props: any, keys: readonly any[]) { } export * from "./date"; +export * from "./useAnimationPresence"; export * from "./useControllableState"; diff --git a/src/presence/helpers.tsx b/src/utils/useAnimationPresence/helpers.tsx similarity index 100% rename from src/presence/helpers.tsx rename to src/utils/useAnimationPresence/helpers.tsx diff --git a/src/utils/useAnimationPresence/index.ts b/src/utils/useAnimationPresence/index.ts new file mode 100644 index 000000000..6798ad331 --- /dev/null +++ b/src/utils/useAnimationPresence/index.ts @@ -0,0 +1,2 @@ +export * from "./helpers"; +export * from "./useAnimationPresence"; diff --git a/src/presence/PresenceState.tsx b/src/utils/useAnimationPresence/useAnimationPresence.tsx similarity index 89% rename from src/presence/PresenceState.tsx rename to src/utils/useAnimationPresence/useAnimationPresence.tsx index 176160411..c2040bdf8 100644 --- a/src/presence/PresenceState.tsx +++ b/src/utils/useAnimationPresence/useAnimationPresence.tsx @@ -1,26 +1,14 @@ -// Inspired from Radix UI Presence - https://github.com/radix-ui/primitives/tree/main/packages/react/presence +// Inspired from Radix UI AnimationPresence - https://github.com/radix-ui/primitives/tree/main/packages/react/presence import * as React from "react"; import { useSafeLayoutEffect } from "@chakra-ui/hooks"; import { getAnimationName, useStateMachine } from "./helpers"; -export type PresenceState = { - isPresent: boolean; -}; - -export type PresenceActions = { - ref: (node: HTMLElement) => void; -}; - -export type PresenceStateReturn = PresenceState & PresenceActions; - -export type PresenceInitialState = { +export type UseAnimationPresenceProps = { present?: boolean; }; -export const usePresenceState = ( - props: PresenceInitialState = {}, -): PresenceStateReturn => { +export const useAnimationPresence = (props: UseAnimationPresenceProps = {}) => { const { present } = props; const [node, setNode] = React.useState(); @@ -130,3 +118,7 @@ export const usePresenceState = ( }, []), }; }; + +export type useAnimationPresenceReturnType = ReturnType< + typeof useAnimationPresence +>; From 8a84eae50742e26d661d7c7b463f2d93f7145421 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Sat, 20 Nov 2021 16:35:49 +0530 Subject: [PATCH 13/13] =?UTF-8?q?refactor(disclosure):=20=E2=99=BB?= =?UTF-8?q?=EF=B8=8F=20rename=20animation=20&=20transition=20to=20be=20uni?= =?UTF-8?q?que?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 +- src/dialog/stories/DialogBasic.component.tsx | 4 +- src/disclosure/DisclosureContent.tsx | 28 ++-- src/disclosure/__keys.ts | 5 +- .../stories/DisclosureBasic.component.tsx | 6 +- .../DisclosureHorizontal.component.tsx | 6 +- src/drawer/stories/DrawerBasic.component.tsx | 8 +- src/index.ts | 1 - .../stories/PopoverBasic.component.tsx | 2 +- .../stories/TooltipBasic.component.tsx | 2 +- .../stories/TooltipCustomAnchor.component.tsx | 6 +- .../TooltipCustomContent.component.tsx | 2 +- .../TooltipCustomDuration.component.tsx | 2 +- .../stories/TooltipPositions.component.tsx | 2 +- yarn.lock | 122 ++++++++++++++---- 15 files changed, 147 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index 6fa452dfe..cc947ca43 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,6 @@ "@chakra-ui/react-utils": "^1.1.2", "@chakra-ui/utils": "^1.8.3", "@radix-ui/popper": "^0.1.0", - "@radix-ui/react-presence": "^0.1.1", "@radix-ui/react-use-rect": "^0.1.1", "@radix-ui/react-use-size": "^0.1.0", "@react-aria/i18n": "^3.3.2", @@ -99,7 +98,9 @@ "@react-aria/spinbutton": "^3.0.1", "@react-aria/utils": "^3.9.0", "date-fns": "^2.25.0", + "framer-motion": "^5.3.1", "react-remove-scroll": "^2.4.3", + "react-spring": "^9.3.1", "reakit-system": "^0.15.2", "reakit-utils": "^0.15.2", "reakit-warning": "^0.6.2" diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx index dbd14a5f2..7a4466d41 100644 --- a/src/dialog/stories/DialogBasic.component.tsx +++ b/src/dialog/stories/DialogBasic.component.tsx @@ -18,12 +18,12 @@ export const DialogBasic: React.FC = props => { return ( <> Open dialog - + Welcome to Reakit!
diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index 64c2602ba..047812fa7 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -22,12 +22,12 @@ export type DisclosureContentOptions = BoxOptions & /** * Whether it uses animation or not. */ - animation?: boolean; + animationPresent?: boolean; /** * Whether it uses animation or not. */ - transition?: boolean; + transitionPresent?: boolean; /** * Whether the content is hidden or not. @@ -59,7 +59,11 @@ export const disclosureComposableContent = createComposableHook< keys: DISCLOSURE_CONTENT_KEYS, useOptions(options, htmlProps) { - const { visible, animation = false, transition = false } = options; + const { + visible, + animationPresent = false, + transitionPresent = false, + } = options; const { isPresent: present, ref: animationRef } = useAnimationPresence({ present: visible, }); @@ -73,7 +77,7 @@ export const disclosureComposableContent = createComposableHook< visible, }); const { transitionState, transitioning, onEnd } = useTransitionPresence({ - transition, + transition: transitionPresent, visible, }); @@ -81,9 +85,9 @@ export const disclosureComposableContent = createComposableHook< // when closing we delay `present` to retrieve dimensions before closing const isVisible = visible || isPresent; const isHidden = - (animation && !isVisible) || - (transition && !visible && !transitioning) || - (!animation && !transition && !isVisible); + (animationPresent && !isVisible) || + (transitionPresent && !visible && !transitioning) || + (!animationPresent && !transitionPresent && !isVisible); return { ...options, @@ -102,8 +106,8 @@ export const disclosureComposableContent = createComposableHook< visible, baseId, presenceRef, - transition, - animation, + transitionPresent, + animationPresent, onEnd, contentWidth: width, contentHeight: height, @@ -139,11 +143,13 @@ export const disclosureComposableContent = createComposableHook< id: baseId, hidden: isHidden, "data-enter": - (transition && transitionState === "enter") || (animation && visible) + (transitionPresent && transitionState === "enter") || + (animationPresent && visible) ? "" : undefined, "data-leave": - (transition && transitionState === "leave") || (animation && !visible) + (transitionPresent && transitionState === "leave") || + (animationPresent && !visible) ? "" : undefined, onTransitionEnd, diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts index 73ff763e1..55a38ae51 100644 --- a/src/disclosure/__keys.ts +++ b/src/disclosure/__keys.ts @@ -18,10 +18,11 @@ export const USE_DISCLOSURE_STATE_KEYS = [ export const DISCLOSURE_KEYS = DISCLOSURE_STATE_KEYS; export const DISCLOSURE_CONTENT_KEYS = [ ...DISCLOSURE_KEYS, - "animation", - "transition", + "animationPresent", + "transitionPresent", "isHidden", "presenceRef", + "present", "transitionState", "onEnd", "contentWidth", diff --git a/src/disclosure/stories/DisclosureBasic.component.tsx b/src/disclosure/stories/DisclosureBasic.component.tsx index 809f2c597..d9695e66b 100644 --- a/src/disclosure/stories/DisclosureBasic.component.tsx +++ b/src/disclosure/stories/DisclosureBasic.component.tsx @@ -15,7 +15,11 @@ export const DisclosureBasic: React.FC = props => { return (
Show More - + Item 1 Item 2 Item 3 diff --git a/src/disclosure/stories/DisclosureHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontal.component.tsx index 0b2d42027..75f0c2dd1 100644 --- a/src/disclosure/stories/DisclosureHorizontal.component.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.component.tsx @@ -16,7 +16,11 @@ export const DisclosureHorizontal: React.FC = return (
Show More - +
Item 1
Item 2
Item 3
diff --git a/src/drawer/stories/DrawerBasic.component.tsx b/src/drawer/stories/DrawerBasic.component.tsx index a7b05be81..e2f66b7ee 100644 --- a/src/drawer/stories/DrawerBasic.component.tsx +++ b/src/drawer/stories/DrawerBasic.component.tsx @@ -27,7 +27,11 @@ export const DrawerBasic: React.FC = props => { - + = props => { transform: ${cssTransforms[placement]}; } `} - transition={true} + transitionPresent={true} unstable_initialFocusRef={inputRef} > X diff --git a/src/index.ts b/src/index.ts index 46ffd3928..5e70f503b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,6 @@ export * from "./number-input"; export * from "./pagination"; export * from "./picker-base"; export * from "./popover"; -export * from "./presence"; export * from "./progress"; export * from "./radio"; export * from "./segment"; diff --git a/src/popover/stories/PopoverBasic.component.tsx b/src/popover/stories/PopoverBasic.component.tsx index 6cbf228b5..54bda6d43 100644 --- a/src/popover/stories/PopoverBasic.component.tsx +++ b/src/popover/stories/PopoverBasic.component.tsx @@ -42,7 +42,7 @@ export const PopoverBasic: React.FC = props => { - +
diff --git a/src/tooltip/stories/TooltipBasic.component.tsx b/src/tooltip/stories/TooltipBasic.component.tsx index 4fa52ee4e..51dfc03e4 100644 --- a/src/tooltip/stories/TooltipBasic.component.tsx +++ b/src/tooltip/stories/TooltipBasic.component.tsx @@ -27,7 +27,7 @@ export const TooltipBasic: React.FC = props => { - +
Tooltip
diff --git a/src/tooltip/stories/TooltipCustomAnchor.component.tsx b/src/tooltip/stories/TooltipCustomAnchor.component.tsx index b7f3e70b2..70c16e9bd 100644 --- a/src/tooltip/stories/TooltipCustomAnchor.component.tsx +++ b/src/tooltip/stories/TooltipCustomAnchor.component.tsx @@ -36,7 +36,11 @@ export const TooltipCustomAnchor: React.FC = - +
Tooltip Content
diff --git a/src/tooltip/stories/TooltipCustomContent.component.tsx b/src/tooltip/stories/TooltipCustomContent.component.tsx index a2fefb009..89d17d829 100644 --- a/src/tooltip/stories/TooltipCustomContent.component.tsx +++ b/src/tooltip/stories/TooltipCustomContent.component.tsx @@ -121,7 +121,7 @@ export const TooltipBasic: React.FC = props => { - + {children} diff --git a/src/tooltip/stories/TooltipCustomDuration.component.tsx b/src/tooltip/stories/TooltipCustomDuration.component.tsx index 1c3e5c436..627d2ddee 100644 --- a/src/tooltip/stories/TooltipCustomDuration.component.tsx +++ b/src/tooltip/stories/TooltipCustomDuration.component.tsx @@ -53,7 +53,7 @@ export const TooltipBasic: React.FC = props => { - + TooltipContent diff --git a/src/tooltip/stories/TooltipPositions.component.tsx b/src/tooltip/stories/TooltipPositions.component.tsx index 412dc1dba..7df609c55 100644 --- a/src/tooltip/stories/TooltipPositions.component.tsx +++ b/src/tooltip/stories/TooltipPositions.component.tsx @@ -136,7 +136,7 @@ export const TooltipBasic: React.FC = props => { - +
Tooltip
diff --git a/yarn.lock b/yarn.lock index 46bacc0e4..2af2351e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2781,7 +2781,7 @@ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== -"@emotion/is-prop-valid@0.8.8", "@emotion/is-prop-valid@^0.8.6": +"@emotion/is-prop-valid@0.8.8", "@emotion/is-prop-valid@^0.8.2", "@emotion/is-prop-valid@^0.8.6": version "0.8.8" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== @@ -3429,29 +3429,6 @@ "@babel/runtime" "^7.13.10" csstype "^3.0.4" -"@radix-ui/react-compose-refs@0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-0.1.0.tgz#cff6e780a0f73778b976acff2c2a5b6551caab95" - integrity sha512-eyclbh+b77k+69Dk72q3694OHrn9B3QsoIRx7ywX341U9RK1ThgQjMFZoPtmZNQTksXHLNEiefR8hGVeFyInGg== - dependencies: - "@babel/runtime" "^7.13.10" - -"@radix-ui/react-presence@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-0.1.1.tgz#2088dec6f4f8042f83dd2d6bf9e8ef09dadbbc15" - integrity sha512-LsL+NcWDpFUAYCmXeH02o4pgqcSLpwxP84UIjCtpIKrsPe2vLuhcp79KC/jZJeXz+of2lUpMAxpM+eCpxFZtlg== - dependencies: - "@babel/runtime" "^7.13.10" - "@radix-ui/react-compose-refs" "0.1.0" - "@radix-ui/react-use-layout-effect" "0.1.0" - -"@radix-ui/react-use-layout-effect@0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz#ebf71bd6d2825de8f1fbb984abf2293823f0f223" - integrity sha512-+wdeS51Y+E1q1Wmd+1xSSbesZkpVj4jsg0BojCbopWvgq5iBvixw5vgemscdh58ep98BwUbsFYnrywFhV9yrVg== - dependencies: - "@babel/runtime" "^7.13.10" - "@radix-ui/react-use-rect@^0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz#6c15384beee59c086e75b89a7e66f3d2e583a856" @@ -3603,6 +3580,26 @@ "@react-spring/shared" "~9.3.0" "@react-spring/types" "~9.3.0" +"@react-spring/konva@~9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@react-spring/konva/-/konva-9.3.0.tgz#97b23b2f235a9805d39279a0a1027c7d9646d6fb" + integrity sha512-lyUWxzEateE6Qxpc81oxJb5yiNDdj36Q9R9euJAgjl2dvUDaX85rVGqaB25+72yA1iQg5I4Kymj3UZVvPthRlA== + dependencies: + "@react-spring/animated" "~9.3.0" + "@react-spring/core" "~9.3.0" + "@react-spring/shared" "~9.3.0" + "@react-spring/types" "~9.3.0" + +"@react-spring/native@~9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@react-spring/native/-/native-9.3.0.tgz#6fee1ccaa8d70a19c239b27e95bcc050776f1725" + integrity sha512-lvKV5qxqnE5AMtTHv8xwAocGED4+VRxpljwBl1lbtileq3WnvOn7CpMLZNGc5TXjLWAE3zfoNJui69/jE/3uSw== + dependencies: + "@react-spring/animated" "~9.3.0" + "@react-spring/core" "~9.3.0" + "@react-spring/shared" "~9.3.0" + "@react-spring/types" "~9.3.0" + "@react-spring/rafz@~9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.3.0.tgz#e791c0ae854f7c1a512ae87f34fff36934d82d29" @@ -3616,12 +3613,22 @@ "@react-spring/rafz" "~9.3.0" "@react-spring/types" "~9.3.0" +"@react-spring/three@~9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@react-spring/three/-/three-9.3.0.tgz#e3fc49de1411eb1a7aa937fec8db33252f11d294" + integrity sha512-RKMXXdcNK0nbwLbmle/0KT/idGGpOxvI5lT1KtN8R3cgJWQBKYWVtzg+B/RgmQVNxO/QNlsKGWTjURockTRSVQ== + dependencies: + "@react-spring/animated" "~9.3.0" + "@react-spring/core" "~9.3.0" + "@react-spring/shared" "~9.3.0" + "@react-spring/types" "~9.3.0" + "@react-spring/types@~9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.3.0.tgz#54ec58ca40414984209c8baa75fddd394f9e2949" integrity sha512-q4cDr2RSPblXMD3Rxvk6qcC7nmhhfV2izEBP06hb8ZCXznA6qJirG3RMpi29kBtEQiw1lWR59hAXKhauaPtbOA== -"@react-spring/web@9.3.0": +"@react-spring/web@9.3.0", "@react-spring/web@~9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.3.0.tgz#48d1ebdd1d484065e0a943dbbb343af259496427" integrity sha512-OTAGKRdyz6fLRR1tABFyw9KMpytyATIndQrj0O6RG47GfjiInpf4+WZKxo763vpS7z1OlnkI81WLUm/sqOqAnA== @@ -3631,6 +3638,16 @@ "@react-spring/shared" "~9.3.0" "@react-spring/types" "~9.3.0" +"@react-spring/zdog@~9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@react-spring/zdog/-/zdog-9.3.0.tgz#d84b69375017d864514ebcf59511731c5cc0280f" + integrity sha512-JOQwtg/MQ6sWwmKNY4w/R1TVXohIUkrbSgDfgUEK45ERTDwZGZzIo9QbqHv4dwEBK4Wa2Hfrcdf8cnEaNNzdAQ== + dependencies: + "@react-spring/animated" "~9.3.0" + "@react-spring/core" "~9.3.0" + "@react-spring/shared" "~9.3.0" + "@react-spring/types" "~9.3.0" + "@react-stately/utils@^3.2.2": version "3.2.2" resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.2.2.tgz#468eafa60740c6b0b847a368215dfaa55e87f505" @@ -10617,6 +10634,19 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +framer-motion@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-5.3.1.tgz#5588f904f16b20a617f112ee2b10573e7392c488" + integrity sha512-wBqW2eAoIxGcp6zhIPlzHq9b6FqWG3Xir6ovgg/HZgbKajGLfcfYRsQU6qri0U53Qov5QZwb7CkfMjl0UIxL4A== + dependencies: + framesync "6.0.1" + hey-listen "^1.0.8" + popmotion "11.0.0" + style-value-types "5.0.0" + tslib "^2.1.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + framesync@5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/framesync/-/framesync-5.3.0.tgz#0ecfc955e8f5a6ddc8fdb0cc024070947e1a0d9b" @@ -10624,6 +10654,13 @@ framesync@5.3.0: dependencies: tslib "^2.1.0" +framesync@6.0.1, framesync@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20" + integrity sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA== + dependencies: + tslib "^2.1.0" + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -11565,6 +11602,11 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== +hey-listen@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" + integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== + highlight.js@^10.1.1, highlight.js@~10.7.0: version "10.7.3" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" @@ -15877,6 +15919,16 @@ polished@^4.0.5: dependencies: "@babel/runtime" "^7.14.0" +popmotion@11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.0.tgz#910e2e7077d9aeba520db8744d40bb5354992212" + integrity sha512-kJDyaG00TtcANP5JZ51od+DCqopxBm2a/Txh3Usu23L9qntjY5wumvcVf578N8qXEHR1a+jx9XCv8zOntdYalQ== + dependencies: + framesync "^6.0.1" + hey-listen "^1.0.8" + style-value-types "5.0.0" + tslib "^2.1.0" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -16727,6 +16779,18 @@ react-sizeme@^3.0.1: shallowequal "^1.1.0" throttle-debounce "^3.0.1" +react-spring@^9.3.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-9.3.1.tgz#5f9587d42a524c8bfeb870458d32755ceee9801a" + integrity sha512-YbWcG+LiiyMi4RnBrMudVj2Rqnt2yEozeISkW5Hm2SOss+uDS4CpVjLuwdFAwczw99FSPrRfyAlGjEx3HvOWiw== + dependencies: + "@react-spring/core" "~9.3.0" + "@react-spring/konva" "~9.3.0" + "@react-spring/native" "~9.3.0" + "@react-spring/three" "~9.3.0" + "@react-spring/web" "~9.3.0" + "@react-spring/zdog" "~9.3.0" + react-style-singleton@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.1.1.tgz#ce7f90b67618be2b6b94902a30aaea152ce52e66" @@ -18494,6 +18558,14 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" +style-value-types@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-5.0.0.tgz#76c35f0e579843d523187989da866729411fc8ad" + integrity sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA== + dependencies: + hey-listen "^1.0.8" + tslib "^2.1.0" + stylis@^4.0.10, stylis@^4.0.3: version "4.0.10" resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.10.tgz#446512d1097197ab3f02fb3c258358c3f7a14240"