diff --git a/src/components/pop-out/PopOut.css.ts b/src/components/pop-out/PopOut.css.ts new file mode 100644 index 0000000..51bb7e9 --- /dev/null +++ b/src/components/pop-out/PopOut.css.ts @@ -0,0 +1,19 @@ +import { style } from "@vanilla-extract/css"; +import { config } from "../../theme"; + +export const PopOut = style({ + position: "fixed", + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: config.zIndex.Max, + ...style, +}); + +export const PopOutContainer = style({ + display: "inline-block", + position: "fixed", + maxWidth: "100vw", + maxHeight: "100vh", +}); diff --git a/src/components/pop-out/PopOut.stories.tsx b/src/components/pop-out/PopOut.stories.tsx index cb545a1..0f7b61e 100644 --- a/src/components/pop-out/PopOut.stories.tsx +++ b/src/components/pop-out/PopOut.stories.tsx @@ -1,31 +1,54 @@ import FocusTrap from "focus-trap-react"; -import React, { useState } from "react"; -import { ComponentMeta } from "@storybook/react"; +import React, { MouseEventHandler, useState } from "react"; +import { ComponentMeta, ComponentStory } from "@storybook/react"; import { Text } from "../text"; import { PopOut } from "./PopOut"; import { Menu, MenuItem } from "../menu"; import { Icon, Icons } from "../icon"; import { IconButton } from "../icon-button"; import { config } from "../../theme/config.css"; +import { Box } from "../box"; +import { RectCords } from "../util"; export default { title: "PopOut", component: PopOut, } as ComponentMeta; -export const Interactive = () => { - const [open, setOpen] = useState(false); +const Template: ComponentStory = (args) => { + const [anchor, setAnchor] = useState(); + + const handleOpen: MouseEventHandler = (evt) => { + const rect = evt.currentTarget?.getBoundingClientRect(); + setAnchor(anchor ? undefined : rect); + }; + const handleContextOpen: MouseEventHandler = (evt) => { + evt.preventDefault(); + const rect = { + x: evt.clientX, + y: evt.clientY, + width: 0, + height: 0, + }; + setAnchor(anchor ? undefined : rect); + }; return ( -
+ setOpen(false), + onDeactivate: () => setAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === "ArrowDown", isKeyBackward: (evt: KeyboardEvent) => evt.key === "ArrowUp", @@ -44,13 +67,12 @@ export const Interactive = () => { } - > - {(ref) => ( - setOpen((state) => !state)} ref={ref}> - - - )} - -
+ /> + + + + ); }; + +export const Interactive = Template.bind({}); diff --git a/src/components/pop-out/PopOut.tsx b/src/components/pop-out/PopOut.tsx index ae09a00..a90e196 100644 --- a/src/components/pop-out/PopOut.tsx +++ b/src/components/pop-out/PopOut.tsx @@ -1,64 +1,53 @@ -import React, { - ReactNode, - RefCallback, - useCallback, - useEffect, - useLayoutEffect, - useRef, -} from "react"; -import { config } from "../../theme"; +import classNames from "classnames"; +import React, { ReactNode, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { as } from "../as"; import { Portal } from "../portal"; -import { Align, getRelativeFixedPosition, Position } from "../util"; +import { Align, getRelativeFixedPosition, Position, RectCords } from "../util"; +import * as css from "./PopOut.css"; export interface PopOutProps { - open: boolean; + anchor?: RectCords; position?: Position; align?: Align; offset?: number; alignOffset?: number; content: ReactNode; - children: (anchorRef: RefCallback) => ReactNode; } export const PopOut = as<"div", PopOutProps>( ( { as: AsPopOut = "div", - open, + className, + anchor, position = "Bottom", align = "Center", offset = 10, alignOffset = 0, content, children, - style, ...props }, ref ) => { - const anchorRef = useRef(null); const baseRef = useRef(null); const positionPopOut = useCallback(() => { - const anchor = anchorRef.current; const baseEl = baseRef.current; - if (!anchor) return; - if (!baseEl) return; + if (!baseEl || !anchor) return; - const css = getRelativeFixedPosition( - anchor.getBoundingClientRect(), + const pCSS = getRelativeFixedPosition( + anchor, + baseEl.getBoundingClientRect(), position, align, offset, - alignOffset, - baseEl.getBoundingClientRect() + alignOffset ); - baseEl.style.top = css.top; - baseEl.style.bottom = css.bottom; - baseEl.style.left = css.left; - baseEl.style.right = css.right; - baseEl.style.transform = css.transform; - }, [position, align, offset, alignOffset]); + baseEl.style.top = pCSS.top ?? "unset"; + baseEl.style.bottom = pCSS.bottom ?? "unset"; + baseEl.style.left = pCSS.left ?? "unset"; + baseEl.style.right = pCSS.right ?? "unset"; + }, [anchor, position, align, offset, alignOffset]); useEffect(() => { window.addEventListener("resize", positionPopOut); @@ -68,45 +57,21 @@ export const PopOut = as<"div", PopOutProps>( }, [positionPopOut]); useLayoutEffect(() => { - if (open) positionPopOut(); - }, [open, positionPopOut]); - - const handleAnchorRef: RefCallback = useCallback((element) => { - anchorRef.current = element; - }, []); + positionPopOut(); + }, [positionPopOut]); return ( <> - {children(handleAnchorRef)} - - {open && ( - -
+ {children} + {anchor && ( + + +
{content}
- )} -
+ + )} ); } diff --git a/src/components/tooltip/Tooltip.css.ts b/src/components/tooltip/Tooltip.css.ts index 31ef50f..dfa934f 100644 --- a/src/components/tooltip/Tooltip.css.ts +++ b/src/components/tooltip/Tooltip.css.ts @@ -1,4 +1,4 @@ -import { ComplexStyleRule, keyframes } from "@vanilla-extract/css"; +import { ComplexStyleRule, keyframes, style } from "@vanilla-extract/css"; import { recipe, RecipeVariants } from "@vanilla-extract/recipes"; import { color } from "../../theme/color.css"; import { config } from "../../theme/config.css"; @@ -53,3 +53,12 @@ export const Tooltip = recipe({ }); export type TooltipVariants = RecipeVariants; + +export const TooltipProvider = style({ + display: "inline-block", + position: "fixed", + maxWidth: "100vw", + maxHeight: "100vh", + zIndex: config.zIndex.Max, + pointerEvents: "none", +}); diff --git a/src/components/tooltip/Tooltip.stories.tsx b/src/components/tooltip/Tooltip.stories.tsx index f0e7fc2..feb9f7b 100644 --- a/src/components/tooltip/Tooltip.stories.tsx +++ b/src/components/tooltip/Tooltip.stories.tsx @@ -35,19 +35,207 @@ Surface.args = { }; export const Interactive = () => ( - + + + + Top Start + + + } + > + {(ref) => ( + + + + )} + + + + Top Center + + + } + > + {(ref) => ( + + + + )} + + + + Top End + + + } + > + {(ref) => ( + + + + )} + + + + Right Start + + + } + > + {(ref) => ( + + + + )} + + + + Right Center + + + } + > + {(ref) => ( + + + + )} + + + Right End + + + } + > + {(ref) => ( + + + + )} + + + + Bottom Start + + + } + > + {(ref) => ( + + + + )} + + + + Bottom Center + + + } + > + {(ref) => ( + + + + )} + + + + Bottom End + + + } + > + {(ref) => ( + + + + )} + + + + Left Start + + + } + > + {(ref) => ( + + + + )} + + + + Left Center + + + } + > + {(ref) => ( + + + + )} + + - Tooltip is Long + Left End } > {(ref) => ( - + )} diff --git a/src/components/tooltip/Tooltip.tsx b/src/components/tooltip/Tooltip.tsx index 3048f0b..8981193 100644 --- a/src/components/tooltip/Tooltip.tsx +++ b/src/components/tooltip/Tooltip.tsx @@ -9,7 +9,6 @@ import React, { useRef, useState, } from "react"; -import { config } from "../../theme"; import { as } from "../as"; import { Portal } from "../portal"; import { Align, getRelativeFixedPosition, Position } from "../util"; @@ -44,17 +43,16 @@ const useTooltip = ( const tooltipCss = getRelativeFixedPosition( anchor.getBoundingClientRect(), + baseEl.getBoundingClientRect(), position, align, offset, - alignOffset, - baseEl.getBoundingClientRect() + alignOffset ); - baseEl.style.top = tooltipCss.top; - baseEl.style.bottom = tooltipCss.bottom; - baseEl.style.left = tooltipCss.left; - baseEl.style.right = tooltipCss.right; - baseEl.style.transform = tooltipCss.transform; + baseEl.style.top = tooltipCss.top ?? "unset"; + baseEl.style.bottom = tooltipCss.bottom ?? "unset"; + baseEl.style.left = tooltipCss.left ?? "unset"; + baseEl.style.right = tooltipCss.right ?? "unset"; }, [position, align, offset, alignOffset]); useEffect(() => { @@ -130,6 +128,7 @@ export const TooltipProvider = as<"div", TooltipProviderProps>( ( { as: AsTooltipProvider = "div", + className, position = "Top", align = "Center", offset = 10, @@ -137,7 +136,6 @@ export const TooltipProvider = as<"div", TooltipProviderProps>( delay = 200, tooltip, children, - style, ...props }, ref @@ -151,15 +149,7 @@ export const TooltipProvider = as<"div", TooltipProviderProps>( { baseRef(instance); diff --git a/src/components/util.ts b/src/components/util.ts index ec6d29e..32e3ed8 100644 --- a/src/components/util.ts +++ b/src/components/util.ts @@ -1,80 +1,258 @@ export interface PositionCSS { - top: string; - right: string; - bottom: string; - left: string; - transform: string; + top?: string; + right?: string; + bottom?: string; + left?: string; } export type Position = "Top" | "Right" | "Bottom" | "Left"; export type Align = "Start" | "Center" | "End"; +export type RectCords = { + x: number; + y: number; + width: number; + height: number; +}; + +const canAlignXStart = (anchor: RectCords, target: RectCords, offset: number): boolean => + anchor.x + offset + target.width <= document.documentElement.clientWidth; +const canAlignXCenter = (anchor: RectCords, target: RectCords, offset: number): boolean => { + const xCenter = anchor.x + anchor.width / 2; + const left = xCenter - target.width / 2; + if (left < 0) return false; + + return left + offset + target.width <= document.documentElement.clientWidth; +}; +const canAlignXEnd = (anchor: RectCords, target: RectCords, offset: number): boolean => { + const xEnd = anchor.x + anchor.width; + return xEnd - (target.width + offset) >= 0; +}; +const canAlignYStart = (anchor: RectCords, target: RectCords, offset: number): boolean => + anchor.y + offset + target.height <= document.documentElement.clientHeight; +const canAlignYCenter = (anchor: RectCords, target: RectCords, offset: number): boolean => { + const yCenter = anchor.y + anchor.height / 2; + const top = yCenter - target.height / 2; + if (top < 0) return false; + + return top + offset + target.height <= document.documentElement.clientHeight; +}; +const canAlignYEnd = (anchor: RectCords, target: RectCords, offset: number): boolean => { + const yEnd = anchor.y + anchor.height; + + return yEnd - (target.height + offset) >= 0; +}; + +const alignXStart = (anchor: RectCords, offset: number): PositionCSS => ({ + left: `${anchor.x + offset}px`, +}); +const alignXCenter = (anchor: RectCords, target: RectCords, offset: number): PositionCSS => { + const xCenter = anchor.x + anchor.width / 2; + const left = xCenter - target.width / 2; + + return { + left: `${left + offset}px`, + }; +}; +const alignXEnd = (anchor: RectCords, offset: number): PositionCSS => { + const xEnd = anchor.x + anchor.width; + const right = document.documentElement.clientWidth - xEnd; + + return { + right: `${right + offset}px`, + }; +}; +const alignXAuto = ( + anchor: RectCords, + target: RectCords, + offset: number +): PositionCSS | undefined => { + if (canAlignXCenter(anchor, target, offset)) { + return alignXCenter(anchor, target, offset); + } + if (canAlignXStart(anchor, target, offset)) { + return alignXStart(anchor, offset); + } + if (canAlignXEnd(anchor, target, offset)) { + return alignXEnd(anchor, offset); + } + return undefined; +}; +const alignX = ( + align: Align, + anchor: RectCords, + target: RectCords, + offset: number +): PositionCSS | undefined => { + if (align === "Start" && canAlignXStart(anchor, target, offset)) { + return alignXStart(anchor, offset); + } + if (align === "Center" && canAlignXCenter(anchor, target, offset)) { + return alignXCenter(anchor, target, offset); + } + if (align === "End" && canAlignXEnd(anchor, target, offset)) { + return alignXEnd(anchor, offset); + } + return alignXAuto(anchor, target, offset); +}; + +const alignYStart = (anchor: RectCords, offset: number): PositionCSS => ({ + top: `${anchor.y + offset}px`, +}); +const alignYCenter = (anchor: RectCords, target: RectCords, offset: number): PositionCSS => { + const yCenter = anchor.y + anchor.height / 2; + const top = yCenter - target.height / 2; + + return { + top: `${top + offset}px`, + }; +}; +const alignYEnd = (anchor: RectCords, offset: number): PositionCSS => { + const yEnd = anchor.y + anchor.height; + const bottom = document.documentElement.clientHeight - yEnd; + + return { + bottom: `${bottom + offset}px`, + }; +}; +const alignYAuto = ( + anchor: RectCords, + target: RectCords, + offset: number +): PositionCSS | undefined => { + if (canAlignYCenter(anchor, target, offset)) { + return alignYCenter(anchor, target, offset); + } + if (canAlignYStart(anchor, target, offset)) { + return alignYStart(anchor, offset); + } + if (canAlignYEnd(anchor, target, offset)) { + return alignYEnd(anchor, offset); + } + return undefined; +}; +const alignY = ( + align: Align, + anchor: RectCords, + target: RectCords, + offset: number +): PositionCSS | undefined => { + if (align === "Start" && canAlignYStart(anchor, target, offset)) { + return alignYStart(anchor, offset); + } + if (align === "Center" && canAlignYCenter(anchor, target, offset)) { + return alignYCenter(anchor, target, offset); + } + if (align === "End" && canAlignYEnd(anchor, target, offset)) { + return alignYEnd(anchor, offset); + } + return alignYAuto(anchor, target, offset); +}; + +const canPositionTop = (anchor: RectCords, target: RectCords, offset: number): boolean => + target.height + offset <= anchor.y; +const canPositionRight = (anchor: RectCords, target: RectCords, offset: number): boolean => + anchor.x + anchor.width + offset + target.width <= document.documentElement.clientWidth; +const canPositionBottom = (anchor: RectCords, target: RectCords, offset: number): boolean => + anchor.y + anchor.height + offset + target.height <= document.documentElement.clientHeight; +const canPositionLeft = (anchor: RectCords, target: RectCords, offset: number): boolean => + target.width + offset <= anchor.x; + +const positionTop = (anchor: RectCords, offset: number): PositionCSS => { + const bottom = document.documentElement.clientHeight - anchor.y; + return { + bottom: `${bottom + offset}px`, + }; +}; + +const positionRight = (anchor: RectCords, offset: number): PositionCSS => { + const left = anchor.x + anchor.width; + return { + left: `${left + offset}px`, + }; +}; +const positionBottom = (anchor: RectCords, offset: number): PositionCSS => { + const top = anchor.y + anchor.height; + return { + top: `${top + offset}px`, + }; +}; +const positionLeft = (anchor: RectCords, offset: number): PositionCSS => { + const right = document.documentElement.clientWidth - anchor.x; + return { + right: `${right + offset}px`, + }; +}; +const positionAuto = ( + align: Align, + anchor: RectCords, + target: RectCords, + offset: number, + alignOffset: number +): PositionCSS => { + if (canPositionRight(anchor, target, offset)) { + return { + ...positionRight(anchor, offset), + ...alignY(align, anchor, target, alignOffset), + }; + } + if (canPositionBottom(anchor, target, offset)) { + return { + ...positionBottom(anchor, offset), + ...alignX(align, anchor, target, alignOffset), + }; + } + if (canPositionLeft(anchor, target, offset)) { + return { + ...positionLeft(anchor, offset), + ...alignY(align, anchor, target, alignOffset), + }; + } + if (canPositionTop(anchor, target, offset)) { + return { + ...positionTop(anchor, offset), + ...alignX(align, anchor, target, alignOffset), + }; + } + return { + ...alignX(align, anchor, target, alignOffset), + }; +}; + export const getRelativeFixedPosition = ( - anchorRect: DOMRect, + anchor: RectCords, + target: RectCords, position: Position, align: Align, offset: number, - alignOffset: number, - itemRect: DOMRect + alignOffset: number ): PositionCSS => { - const { clientWidth, clientHeight } = document.documentElement; - - const css = { - top: "unset", - right: "unset", - bottom: "unset", - left: "unset", - transform: "none", - }; + if (position === "Top" && canPositionTop(anchor, target, offset)) { + return { + ...positionTop(anchor, offset), + ...alignX(align, anchor, target, alignOffset), + }; + } + if (position === "Right" && canPositionRight(anchor, target, offset)) { + return { + ...positionRight(anchor, offset), + ...alignY(align, anchor, target, alignOffset), + }; + } + if (position === "Bottom" && canPositionBottom(anchor, target, offset)) { + return { + ...positionBottom(anchor, offset), + ...alignX(align, anchor, target, alignOffset), + }; + } + + if (position === "Left" && canPositionLeft(anchor, target, offset)) { + return { + ...positionLeft(anchor, offset), + ...alignY(align, anchor, target, alignOffset), + }; + } - if (position === "Top" || position === "Bottom") { - const top = anchorRect.top - offset; - const bottom = anchorRect.bottom + offset; - const canPositionTop = top >= itemRect.height; - const canPositionBottom = bottom + itemRect.height <= clientHeight; - - if (position === "Top") { - if (canPositionTop) css.bottom = `${clientHeight - top}px`; - else if (canPositionBottom) css.top = `${bottom}px`; - else css.top = `${offset}px`; - } - if (position === "Bottom") { - if (canPositionBottom) css.top = `${bottom}px`; - else if (canPositionTop) css.bottom = `${clientHeight - top}px`; - else css.top = `${offset}px`; - } - - if (align === "Start") css.left = `${anchorRect.left + alignOffset}px`; - if (align === "Center") { - css.left = `${anchorRect.left + anchorRect.width / 2 + alignOffset}px`; - css.transform = "translateX(-50%)"; - } - if (align === "End") css.right = `${clientWidth - anchorRect.right + alignOffset}px`; - } else { - const left = anchorRect.left - offset; - const right = anchorRect.right + offset; - const canPositionLeft = left >= itemRect.width; - const canPositionRight = right + itemRect.width <= clientWidth; - - if (position === "Left") { - if (canPositionLeft) css.right = `${clientWidth - left}px`; - else if (canPositionRight) css.left = `${right}px`; - else css.left = `${offset}px`; - } - if (position === "Right") { - if (canPositionRight) css.left = `${right}px`; - else if (canPositionLeft) css.right = `${clientWidth - left}px`; - else css.left = `${offset}px`; - } - - if (align === "Start") css.top = `${anchorRect.top + alignOffset}px`; - if (align === "Center") { - css.transform = "translateY(-50%)"; - css.top = `${anchorRect.top + anchorRect.height / 2 + alignOffset}px`; - } - if (align === "End") css.bottom = `${clientHeight - anchorRect.bottom + alignOffset}px`; - } - - return css; + return positionAuto(align, anchor, target, offset, alignOffset); }; export const percent = (min: number, max: number, value: number) =>