UNPKG

@vitus-labs/elements

Version:
1,333 lines (1,290 loc) 40.1 kB
import { Provider, alignContent, extendCss, makeItResponsive, value } from "@vitus-labs/unistyle"; import { config, context, isEmpty, omit, pick, render, throttle } from "@vitus-labs/core"; import { Children, createContext, forwardRef, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import { isFragment } from "react-is"; import { createPortal } from "react-dom"; //#region src/constants.ts const PKG_NAME = "@vitus-labs/elements"; //#endregion //#region src/utils.ts const IS_DEVELOPMENT = process.env.NODE_ENV !== "production"; //#endregion //#region src/helpers/Content/styled.ts /** * Styled component for content areas (before/content/after). Applies * responsive flex alignment, gap spacing between slots based on parent * direction (margin-right for inline, margin-bottom for rows), and * equalCols flex distribution. The "content" slot gets `flex: 1` to * fill remaining space between before and after. */ const { styled: styled$2, css: css$2, component: component$1 } = config; const equalColsCSS = ` flex: 1; `; const typeContentCSS = ` flex: 1; `; const gapDimensions = { inline: { before: "margin-right", after: "margin-left" }, reverseInline: { before: "margin-right", after: "margin-left" }, rows: { before: "margin-bottom", after: "margin-top" }, reverseRows: { before: "margin-bottom", after: "margin-top" } }; const calculateGap = ({ direction, type, value }) => { if (!direction || !type || type === "content") return void 0; return `${gapDimensions[direction][type]}: ${value};`; }; const styles$2 = ({ css, theme: t, rootSize }) => css` ${alignContent({ direction: t.direction, alignX: t.alignX, alignY: t.alignY })}; ${t.equalCols && equalColsCSS}; ${t.gap && t.contentType && calculateGap({ direction: t.parentDirection, type: t.contentType, value: value(t.gap, rootSize) })}; ${t.extraStyles && extendCss(t.extraStyles)}; `; const StyledComponent = styled$2(component$1)` ${`box-sizing: border-box;`}; display: flex; align-self: stretch; flex-wrap: wrap; ${({ $contentType }) => $contentType === "content" && typeContentCSS}; ${makeItResponsive({ key: "$element", styles: styles$2, css: css$2, normalize: true })}; `; //#endregion //#region src/helpers/Content/component.tsx /** * Memoized content area used inside Element to render one of the three * layout slots (before, content, after). Passes alignment, direction, * gap, and equalCols styling props to the underlying styled component. * Adds a `data-vl-element` attribute in development for debugging. * * Children are passed as raw content and rendered inside the memo boundary * via core `render()` — this lets React.memo skip re-renders when the * content reference is stable (common for component-type or string content). */ const Component$9 = ({ contentType, tag, parentDirection, direction, alignX, alignY, equalCols, gap, extendCss, children, ...props }) => { const debugProps = IS_DEVELOPMENT ? { "data-vl-element": contentType } : {}; return /* @__PURE__ */ jsx(StyledComponent, { as: tag, $contentType: contentType, $element: useMemo(() => ({ contentType, parentDirection, direction, alignX, alignY, equalCols, gap, extraStyles: extendCss }), [ contentType, parentDirection, direction, alignX, alignY, equalCols, gap, extendCss ]), ...debugProps, ...props, children: render(children) }); }; var component_default$1 = memo(Component$9); //#endregion //#region src/helpers/Content/index.ts var Content_default = component_default$1; //#endregion //#region src/helpers/Wrapper/styled.ts /** * Styled component for the Element wrapper layer. Handles responsive * block/inline-flex display, direction, alignment, and custom CSS injection. * Includes special handling for the `parentFix` / `childFix` flags that * split flex behavior across two DOM nodes for button/fieldset/legend * elements where a single flex container is insufficient. */ const { styled: styled$1, css: css$1, component } = config; const childFixCSS = ` display: flex; flex: 1; width: 100%; height: 100%; `; const parentFixCSS = ` flex-direction: column; `; const parentFixBlockCSS = ` width: 100%; `; const fullHeightCSS = ` height: 100%; `; const blockCSS = ` align-self: stretch; `; const childFixPosition = (isBlock) => `display: ${isBlock ? "flex" : "inline-flex"};`; const styles$1 = ({ theme: t, css }) => css` ${t.alignY === "block" && fullHeightCSS}; ${alignContent({ direction: t.direction, alignX: t.alignX, alignY: t.alignY })}; ${t.block && blockCSS}; ${!t.childFix && childFixPosition(t.block)}; ${t.parentFix && t.block && parentFixBlockCSS}; ${t.parentFix && parentFixCSS}; ${t.extraStyles && extendCss(t.extraStyles)}; `; const platformCSS = `box-sizing: border-box;`; var styled_default$1 = styled$1(component)` position: relative; ${platformCSS}; ${({ $childFix }) => $childFix && childFixCSS}; ${makeItResponsive({ key: "$element", styles: styles$1, css: css$1, normalize: true })}; `; //#endregion //#region src/helpers/Wrapper/constants.ts /** * HTML elements that need a two-layer DOM workaround because browsers do not * fully support flexbox layout on button, fieldset, and legend elements. * @see https://stackoverflow.com/questions/35464067/flexbox-not-working-on-button-or-fieldset-elements */ const INLINE_ELEMENTS_FLEX_FIX = { button: true, fieldset: true, legend: true }; //#endregion //#region src/helpers/Wrapper/utils.ts const isWebFixNeeded = (tag) => { if (tag && tag in INLINE_ELEMENTS_FLEX_FIX) return true; return false; }; //#endregion //#region src/helpers/Wrapper/component.tsx /** * Wrapper component that serves as the outermost styled container for Element. * Uses forwardRef for ref forwarding to the underlying DOM node. On web, it * detects button/fieldset/legend tags and applies a two-layer flex fix * (parent + child Styled) because these HTML elements do not natively * support `display: flex` consistently across browsers. */ const DEV_PROPS = IS_DEVELOPMENT ? { "data-vl-element": "Element" } : {}; const Component$8 = forwardRef(({ children, tag, block, extendCss, direction, alignX, alignY, equalCols, isInline, ...props }, ref) => { const COMMON_PROPS = { ...props, ...DEV_PROPS, ref, as: tag }; const needsFix = !props.dangerouslySetInnerHTML && isWebFixNeeded(tag); const normalElement = useMemo(() => ({ block, direction, alignX, alignY, equalCols, extraStyles: extendCss }), [ block, direction, alignX, alignY, equalCols, extendCss ]); const parentFixElement = useMemo(() => ({ parentFix: true, block, extraStyles: extendCss }), [block, extendCss]); const childFixElement = useMemo(() => ({ childFix: true, direction, alignX, alignY, equalCols }), [ direction, alignX, alignY, equalCols ]); if (!needsFix || false) return /* @__PURE__ */ jsx(styled_default$1, { ...COMMON_PROPS, $element: normalElement, children }); const asTag = isInline ? "span" : "div"; return /* @__PURE__ */ jsx(styled_default$1, { ...COMMON_PROPS, $element: parentFixElement, children: /* @__PURE__ */ jsx(styled_default$1, { as: asTag, $childFix: true, $element: childFixElement, children }) }); }); //#endregion //#region src/helpers/Wrapper/index.ts var Wrapper_default = Component$8; //#endregion //#region src/Element/constants.ts /** * HTML tags that are inline-level by default. When Element renders one of * these tags, child Content wrappers use `span` instead of `div` to * preserve valid HTML nesting. */ const INLINE_ELEMENTS = { span: true, a: true, button: true, input: true, label: true, select: true, textarea: true, br: true, img: true, strong: true, small: true, code: true, b: true, big: true, i: true, tt: true, abbr: true, acronym: true, cite: true, dfn: true, em: true, kbd: true, samp: true, var: true, bdo: true, map: true, object: true, q: true, script: true, sub: true, sup: true }; /** * HTML void/self-closing elements that cannot have children. When Element * detects one of these tags, it skips rendering beforeContent/content/afterContent * and returns the Wrapper alone. */ const EMPTY_ELEMENTS = { area: true, base: true, br: true, col: true, embed: true, hr: true, img: true, input: true, keygen: true, link: true, textarea: true, source: true, track: true, wbr: true }; //#endregion //#region src/Element/utils.ts /** Checks whether the given HTML tag is an inline-level element, used to determine sub-tag nesting. */ const isInlineElement = (tag) => { if (tag && tag in INLINE_ELEMENTS) return true; return false; }; /** Checks whether the given HTML tag is a void element that cannot have children. */ const getShouldBeEmpty = (tag) => { if (tag && tag in EMPTY_ELEMENTS) return true; return false; }; //#endregion //#region src/Element/component.tsx /** * Core building block of the elements package. Renders a three-section layout * (beforeContent / content / afterContent) inside a flex Wrapper. When only * content is present, the Wrapper inherits content-level alignment directly * to avoid an unnecessary nesting layer. Handles HTML-specific edge cases * like void elements (input, img) and inline elements (span, a) by * skipping children or switching sub-tags accordingly. */ const equalize = (el, direction) => { const beforeEl = el.firstElementChild; const afterEl = el.lastElementChild; if (beforeEl && afterEl && beforeEl !== afterEl) { const type = direction === "rows" ? "height" : "width"; const prop = type === "height" ? "offsetHeight" : "offsetWidth"; const beforeSize = beforeEl[prop]; const afterSize = afterEl[prop]; if (Number.isInteger(beforeSize) && Number.isInteger(afterSize)) { const maxSize = `${Math.max(beforeSize, afterSize)}px`; beforeEl.style[type] = maxSize; afterEl.style[type] = maxSize; } } }; const defaultDirection = "inline"; const defaultContentDirection = "rows"; const defaultAlignX = "left"; const defaultAlignY = "center"; const Component$7 = forwardRef(({ innerRef, tag, label, content, children, beforeContent, afterContent, equalBeforeAfter, block, equalCols, gap, direction, alignX = defaultAlignX, alignY = defaultAlignY, css, contentCss, beforeContentCss, afterContentCss, contentDirection = defaultContentDirection, contentAlignX = defaultAlignX, contentAlignY = defaultAlignY, beforeContentDirection = defaultDirection, beforeContentAlignX = defaultAlignX, beforeContentAlignY = defaultAlignY, afterContentDirection = defaultDirection, afterContentAlignX = defaultAlignX, afterContentAlignY = defaultAlignY, ...props }, ref) => { const shouldBeEmpty = !!props.dangerouslySetInnerHTML || getShouldBeEmpty(tag); const isSimpleElement = !beforeContent && !afterContent; const CHILDREN = children ?? content ?? label; const isInline = isInlineElement(tag); const SUB_TAG = isInline ? "span" : void 0; const { wrapperDirection, wrapperAlignX, wrapperAlignY } = useMemo(() => { let wrapperDirection = direction; let wrapperAlignX = alignX; let wrapperAlignY = alignY; if (isSimpleElement) { if (contentDirection) wrapperDirection = contentDirection; if (contentAlignX) wrapperAlignX = contentAlignX; if (contentAlignY) wrapperAlignY = contentAlignY; } else if (direction) wrapperDirection = direction; else wrapperDirection = defaultDirection; return { wrapperDirection, wrapperAlignX, wrapperAlignY }; }, [ isSimpleElement, contentDirection, contentAlignX, contentAlignY, alignX, alignY, direction ]); const equalizeRef = useRef(null); const externalRef = ref ?? innerRef; const mergedRef = useCallback((node) => { equalizeRef.current = node; if (typeof externalRef === "function") externalRef(node); else if (externalRef != null) externalRef.current = node; }, [externalRef]); useLayoutEffect(() => { if (!equalBeforeAfter || !beforeContent || !afterContent) return; if (equalizeRef.current) equalize(equalizeRef.current, direction); }, [ equalBeforeAfter, beforeContent, afterContent, direction ]); const WRAPPER_PROPS = { ref: mergedRef, extendCss: css, tag, block, direction: wrapperDirection, alignX: wrapperAlignX, alignY: wrapperAlignY, as: void 0 }; if (shouldBeEmpty) return /* @__PURE__ */ jsx(Wrapper_default, { ...props, ...WRAPPER_PROPS }); return /* @__PURE__ */ jsxs(Wrapper_default, { ...props, ...WRAPPER_PROPS, isInline, children: [ beforeContent && /* @__PURE__ */ jsx(Content_default, { tag: SUB_TAG, contentType: "before", parentDirection: wrapperDirection, extendCss: beforeContentCss, direction: beforeContentDirection, alignX: beforeContentAlignX, alignY: beforeContentAlignY, equalCols, gap, children: beforeContent }), isSimpleElement ? render(CHILDREN) : /* @__PURE__ */ jsx(Content_default, { tag: SUB_TAG, contentType: "content", parentDirection: wrapperDirection, extendCss: contentCss, direction: contentDirection, alignX: contentAlignX, alignY: contentAlignY, equalCols, children: CHILDREN }), afterContent && /* @__PURE__ */ jsx(Content_default, { tag: SUB_TAG, contentType: "after", parentDirection: wrapperDirection, extendCss: afterContentCss, direction: afterContentDirection, alignX: afterContentAlignX, alignY: afterContentAlignY, equalCols, gap, children: afterContent }) ] }); }); const name$5 = `${PKG_NAME}/Element`; Component$7.displayName = name$5; Component$7.pkgName = PKG_NAME; Component$7.VITUS_LABS__COMPONENT = name$5; //#endregion //#region src/Element/index.ts var Element_default = Component$7; //#endregion //#region src/helpers/Iterator/component.tsx /** * Data-driven list renderer that supports three input modes: React children * (including fragments), an array of primitives, or an array of objects. * Each item receives positional metadata (first, last, odd, even, position) * and optional injected props via `itemProps`. Items can be individually * wrapped with `wrapComponent`. Children always take priority over the * component+data prop pattern. */ const classifyData = (data) => { const items = data.filter((item) => item != null && !(typeof item === "object" && isEmpty(item))); if (items.length === 0) return null; let isSimple = true; let isComplex = true; for (const item of items) if (typeof item === "string" || typeof item === "number") isComplex = false; else if (typeof item === "object") isSimple = false; else { isSimple = false; isComplex = false; } if (isSimple) return { type: "simple", data: items }; if (isComplex) return { type: "complex", data: items }; return null; }; const RESERVED_PROPS = [ "children", "component", "wrapComponent", "data", "itemKey", "valueName", "itemProps", "wrapProps" ]; const attachItemProps = ({ i, length }) => { const position = i + 1; return { index: i, first: position === 1, last: position === length, odd: position % 2 === 1, even: position % 2 === 0, position }; }; const Component$6 = (props) => { const { itemKey, valueName, children, component, data, wrapComponent: Wrapper, wrapProps, itemProps } = props; const injectItemProps = useMemo(() => typeof itemProps === "function" ? itemProps : () => itemProps, [itemProps]); const injectWrapItemProps = useMemo(() => typeof wrapProps === "function" ? wrapProps : () => wrapProps, [wrapProps]); const getKey = useCallback((item, index) => { if (typeof itemKey === "function") return itemKey(item, index); return index; }, [itemKey]); const renderChild = (child, total = 1, i = 0) => { if (!itemProps && !Wrapper) return child; const extendedProps = attachItemProps({ i, length: total }); const finalItemProps = itemProps ? injectItemProps({}, extendedProps) : {}; if (Wrapper) return /* @__PURE__ */ jsx(Wrapper, { ...wrapProps ? injectWrapItemProps({}, extendedProps) : {}, children: render(child, finalItemProps) }, i); return render(child, { key: i, ...finalItemProps }); }; const renderChildren = () => { if (!children) return null; if (Array.isArray(children)) return Children.map(children, (item, i) => renderChild(item, children.length, i)); if (isFragment(children)) { const fragmentChildren = children.props.children; const childrenLength = fragmentChildren.length; return fragmentChildren.map((item, i) => renderChild(item, childrenLength, i)); } return renderChild(children); }; const renderSimpleArray = (data) => { const { length } = data; if (length === 0) return null; return data.map((item, i) => { const key = getKey(item, i); const keyName = valueName ?? "children"; const extendedProps = attachItemProps({ i, length }); const finalItemProps = { ...itemProps ? injectItemProps({ [keyName]: item }, extendedProps) : {}, [keyName]: item }; if (Wrapper) return /* @__PURE__ */ jsx(Wrapper, { ...wrapProps ? injectWrapItemProps({ [keyName]: item }, extendedProps) : {}, children: render(component, finalItemProps) }, key); return render(component, { key, ...finalItemProps }); }); }; const getObjectKey = (item, index) => { if (!itemKey) return item.key ?? item.id ?? item.itemId ?? index; if (typeof itemKey === "function") return itemKey(item, index); if (typeof itemKey === "string") return item[itemKey]; return index; }; const renderComplexArray = (data) => { const { length } = data; if (length === 0) return null; return data.map((item, i) => { const { component: itemComponent, ...restItem } = item; const renderItem = itemComponent ?? component; const key = getObjectKey(restItem, i); const extendedProps = attachItemProps({ i, length }); const finalItemProps = { ...itemProps ? injectItemProps(item, extendedProps) : {}, ...restItem }; if (Wrapper && !itemComponent) return /* @__PURE__ */ jsx(Wrapper, { ...wrapProps ? injectWrapItemProps(item, extendedProps) : {}, children: render(renderItem, finalItemProps) }, key); return render(renderItem, { key, ...finalItemProps }); }); }; const renderItems = () => { if (children) return renderChildren(); if (component && Array.isArray(data)) { const classified = classifyData(data); if (!classified) return null; if (classified.type === "simple") return renderSimpleArray(classified.data); return renderComplexArray(classified.data); } return null; }; return renderItems(); }; var component_default = Object.assign(memo(Component$6), { isIterator: true, RESERVED_PROPS }); //#endregion //#region src/helpers/Iterator/index.ts var Iterator_default = component_default; //#endregion //#region src/List/component.tsx /** * List component that combines Iterator (data-driven rendering) with an * optional Element root wrapper. When `rootElement` is false (default), * it renders a bare Iterator as a fragment. When true, the Iterator output * is wrapped in an Element that receives all non-iterator props (e.g., * layout, alignment, css), allowing the list to be styled as a single block. */ const Component$5 = forwardRef(({ rootElement = false, ...props }, ref) => { const renderedList = /* @__PURE__ */ jsx(Iterator_default, { ...pick(props, Iterator_default.RESERVED_PROPS) }); if (!rootElement) return renderedList; return /* @__PURE__ */ jsx(Element_default, { ref, ...omit(props, Iterator_default.RESERVED_PROPS), children: renderedList }); }); const name$4 = `${PKG_NAME}/List`; Component$5.displayName = name$4; Component$5.pkgName = PKG_NAME; Component$5.VITUS_LABS__COMPONENT = name$4; //#endregion //#region src/List/index.ts var List_default = Component$5; //#endregion //#region src/Portal/component.ts /** * Portal component that creates a new DOM element on mount, appends it to * the target location (defaults to document.body), and uses React's * createPortal to render children into it. The DOM element is cleaned up * on unmount. Accepts a custom DOMLocation for rendering into specific * containers (e.g., a modal root). */ const Component$4 = ({ DOMLocation, tag = "div", children }) => { const [element, setElement] = useState(); useEffect(() => { if (!tag) return void 0; const position = DOMLocation ?? document.body; const element = document.createElement(tag); setElement(element); position.appendChild(element); return () => { position.removeChild(element); }; }, [tag, DOMLocation]); if (!tag || !element) return null; return createPortal(children, element); }; const name$3 = `${PKG_NAME}/Portal`; Component$4.displayName = name$3; Component$4.pkgName = PKG_NAME; Component$4.VITUS_LABS__COMPONENT = name$3; //#endregion //#region src/Portal/index.ts var Portal_default = Component$4; //#endregion //#region src/Overlay/context.tsx /** * Context for nested overlay coordination. When a child overlay opens, it * sets the parent's blocked state to true, preventing the parent from * closing in response to click/hover events that belong to the child. */ const context$1 = createContext({}); const { Provider: Provider$1 } = context$1; const useOverlayContext = () => useContext(context$1); const Component = ({ children, blocked, setBlocked, setUnblocked }) => { return /* @__PURE__ */ jsx(Provider$1, { value: useMemo(() => ({ blocked, setBlocked, setUnblocked }), [ blocked, setBlocked, setUnblocked ]), children }); }; //#endregion //#region src/Overlay/useOverlay.tsx /** * Core hook powering the Overlay component. Manages open/close state, DOM * event listeners (click, hover, scroll, resize, ESC key), and dynamic * positioning of overlay content relative to its trigger. Supports dropdown, * tooltip, popover, and modal types with automatic edge-of-viewport flipping. * Event handlers are throttled for performance, and nested overlay blocking * is coordinated through the overlay context. */ const sel = (cond, a, b) => cond ? a : b; const devWarn = (msg) => { if (!IS_DEVELOPMENT) return; console.warn(msg); }; const calcDropdownVertical = (c, t, align, alignX, offsetX, offsetY) => { const pos = {}; const topPos = t.top - offsetY - c.height; const bottomPos = t.bottom + offsetY; const leftPos = t.left + offsetX; const rightPos = t.right - offsetX - c.width; const fitsTop = topPos >= 0; const fitsBottom = bottomPos + c.height <= window.innerHeight; const fitsLeft = leftPos + c.width <= window.innerWidth; const fitsRight = rightPos >= 0; const useTop = sel(align === "top", fitsTop, !fitsBottom); pos.top = sel(useTop, topPos, bottomPos); const resolvedAlignY = sel(useTop, "top", "bottom"); let resolvedAlignX = alignX; if (alignX === "left") { pos.left = sel(fitsLeft, leftPos, rightPos); resolvedAlignX = sel(fitsLeft, "left", "right"); } else if (alignX === "right") { pos.left = sel(fitsRight, rightPos, leftPos); resolvedAlignX = sel(fitsRight, "right", "left"); } else { const center = t.left + (t.right - t.left) / 2 - c.width / 2; const fitsCL = center >= 0; const fitsCR = center + c.width <= window.innerWidth; if (fitsCL && fitsCR) { resolvedAlignX = "center"; pos.left = center; } else if (fitsCL) { resolvedAlignX = "left"; pos.left = leftPos; } else if (fitsCR) { resolvedAlignX = "right"; pos.left = rightPos; } } return { pos, resolvedAlignX, resolvedAlignY }; }; const calcDropdownHorizontal = (c, t, align, alignY, offsetX, offsetY) => { const pos = {}; const leftPos = t.left - offsetX - c.width; const rightPos = t.right + offsetX; const topPos = t.top + offsetY; const bottomPos = t.bottom - offsetY - c.height; const fitsLeft = leftPos >= 0; const fitsRight = rightPos + c.width <= window.innerWidth; const fitsTop = topPos + c.height <= window.innerHeight; const fitsBottom = bottomPos >= 0; const useLeft = sel(align === "left", fitsLeft, !fitsRight); pos.left = sel(useLeft, leftPos, rightPos); const resolvedAlignX = sel(useLeft, "left", "right"); let resolvedAlignY = alignY; if (alignY === "top") { pos.top = sel(fitsTop, topPos, bottomPos); resolvedAlignY = sel(fitsTop, "top", "bottom"); } else if (alignY === "bottom") { pos.top = sel(fitsBottom, bottomPos, topPos); resolvedAlignY = sel(fitsBottom, "bottom", "top"); } else { const center = t.top + (t.bottom - t.top) / 2 - c.height / 2; const fitsCT = center >= 0; const fitsCB = center + c.height <= window.innerHeight; if (fitsCT && fitsCB) { resolvedAlignY = "center"; pos.top = center; } else if (fitsCT) { resolvedAlignY = "top"; pos.top = topPos; } else if (fitsCB) { resolvedAlignY = "bottom"; pos.top = bottomPos; } } return { pos, resolvedAlignX, resolvedAlignY }; }; const calcModalPos = (c, alignX, alignY, offsetX, offsetY) => { const pos = {}; switch (alignX) { case "right": pos.right = offsetX; break; case "left": pos.left = offsetX; break; case "center": pos.left = window.innerWidth / 2 - c.width / 2; break; default: pos.right = offsetX; } switch (alignY) { case "top": pos.top = offsetY; break; case "center": pos.top = window.innerHeight / 2 - c.height / 2; break; case "bottom": pos.bottom = offsetY; break; default: pos.top = offsetY; } return pos; }; const adjustForAncestor = (pos, ancestor) => { if (ancestor.top === 0 && ancestor.left === 0) return pos; const result = { ...pos }; if (typeof result.top === "number") result.top -= ancestor.top; if (typeof result.bottom === "number") result.bottom += ancestor.top; if (typeof result.left === "number") result.left -= ancestor.left; if (typeof result.right === "number") result.right += ancestor.left; return result; }; const computePosition = (type, align, alignX, alignY, offsetX, offsetY, triggerEl, contentEl, ancestorOffset) => { const isDropdown = [ "dropdown", "tooltip", "popover" ].includes(type); if (isDropdown && (!triggerEl || !contentEl)) { devWarn(`[@vitus-labs/elements] Overlay (${type}): ${triggerEl ? "contentRef" : "triggerRef"} is not attached. Position cannot be calculated without both refs.`); return { pos: {} }; } if (isDropdown && triggerEl && contentEl) { const c = contentEl.getBoundingClientRect(); const t = triggerEl.getBoundingClientRect(); const result = align === "top" || align === "bottom" ? calcDropdownVertical(c, t, align, alignX, offsetX, offsetY) : calcDropdownHorizontal(c, t, align, alignY, offsetX, offsetY); return { pos: adjustForAncestor(result.pos, ancestorOffset), resolvedAlignX: result.resolvedAlignX, resolvedAlignY: result.resolvedAlignY }; } if (type === "modal") { if (!contentEl) { devWarn("[@vitus-labs/elements] Overlay (modal): contentRef is not attached. Modal position cannot be calculated without a content element."); return { pos: {} }; } return { pos: adjustForAncestor(calcModalPos(contentEl.getBoundingClientRect(), alignX, alignY, offsetX, offsetY), ancestorOffset) }; } return { pos: {} }; }; const processVisibilityEvent = (e, active, openOn, closeOn, isTrigger, isContent, showContent, hideContent) => { if (!active && openOn === "click" && e.type === "click" && isTrigger(e)) { showContent(); return; } if (!active) return; if (closeOn === "hover" && e.type === "scroll") { hideContent(); return; } if (e.type !== "click") return; if (closeOn === "click") hideContent(); else if (closeOn === "clickOnTrigger" && isTrigger(e)) hideContent(); else if (closeOn === "clickOutsideContent" && !isContent(e)) hideContent(); }; const useOverlay = ({ isOpen = false, openOn = "click", closeOn = "click", type = "dropdown", position = "fixed", align = "bottom", alignX = "left", alignY = "bottom", offsetX = 0, offsetY = 0, throttleDelay = 200, parentContainer, closeOnEsc = true, disabled, onOpen, onClose } = {}) => { const { rootSize } = useContext(context); const ctx = useOverlayContext(); const [isContentLoaded, setContentLoaded] = useState(false); const [innerAlignX, setInnerAlignX] = useState(alignX); const [innerAlignY, setInnerAlignY] = useState(alignY); const [blocked, handleBlocked] = useState(false); const [active, handleActive] = useState(isOpen); const triggerRef = useRef(null); const contentRef = useRef(null); const setBlocked = useCallback(() => handleBlocked(true), []); const setUnblocked = useCallback(() => handleBlocked(false), []); const showContent = useCallback(() => { handleActive(true); }, []); const hideContent = useCallback(() => { handleActive(false); }, []); const getAncestorOffset = useCallback(() => { if (position !== "absolute" || !contentRef.current) return { top: 0, left: 0 }; const offsetParent = contentRef.current.offsetParent; if (!offsetParent || offsetParent === document.body) return { top: 0, left: 0 }; const rect = offsetParent.getBoundingClientRect(); return { top: rect.top, left: rect.left }; }, [position]); const calculateContentPosition = useCallback(() => { if (!active || !isContentLoaded) return {}; const result = computePosition(type, align, alignX, alignY, offsetX, offsetY, triggerRef.current, contentRef.current, getAncestorOffset()); if (result.resolvedAlignX) setInnerAlignX(result.resolvedAlignX); if (result.resolvedAlignY) setInnerAlignY(result.resolvedAlignY); return result.pos; }, [ isContentLoaded, active, align, alignX, alignY, offsetX, offsetY, type, getAncestorOffset ]); const assignContentPosition = useCallback((values = {}) => { if (!contentRef.current) return; const el = contentRef.current; const setValue = (param) => value(param, rootSize); el.style.position = position; el.style.top = values.top != null ? setValue(values.top) : ""; el.style.bottom = values.bottom != null ? setValue(values.bottom) : ""; el.style.left = values.left != null ? setValue(values.left) : ""; el.style.right = values.right != null ? setValue(values.right) : ""; }, [position, rootSize]); const setContentPosition = useCallback(() => { assignContentPosition(calculateContentPosition()); }, [assignContentPosition, calculateContentPosition]); const isNodeOrChild = useCallback((ref) => (e) => { if (e?.target && ref.current) return ref.current.contains(e.target) || e.target === ref.current; return false; }, []); const handleVisibilityByEventType = useCallback((e) => { if (blocked || disabled) return; processVisibilityEvent(e, active, openOn, closeOn, isNodeOrChild(triggerRef), isNodeOrChild(contentRef), showContent, hideContent); }, [ active, blocked, disabled, openOn, closeOn, hideContent, showContent, isNodeOrChild ]); const latestSetContentPosition = useRef(setContentPosition); latestSetContentPosition.current = setContentPosition; const latestHandleVisibility = useRef(handleVisibilityByEventType); latestHandleVisibility.current = handleVisibilityByEventType; const handleContentPosition = useMemo(() => throttle(() => latestSetContentPosition.current(), throttleDelay), [throttleDelay]); const handleClick = handleVisibilityByEventType; const handleVisibility = useMemo(() => throttle((e) => latestHandleVisibility.current(e), throttleDelay), [throttleDelay]); useEffect(() => { setInnerAlignX(alignX); setInnerAlignY(alignY); if (disabled) hideContent(); }, [ disabled, alignX, alignY, hideContent ]); useEffect(() => { if (!active || !isContentLoaded) return void 0; setContentPosition(); const rafId = requestAnimationFrame(() => setContentPosition()); return () => cancelAnimationFrame(rafId); }, [ active, isContentLoaded, setContentPosition ]); const prevActiveRef = useRef(false); useEffect(() => { const wasActive = prevActiveRef.current; prevActiveRef.current = active; if (active && !wasActive) { onOpen?.(); ctx.setBlocked?.(); } else if (!active && wasActive) { setContentLoaded(false); onClose?.(); ctx.setUnblocked?.(); } else if (!active) setContentLoaded(false); return () => { if (active) { onClose?.(); ctx.setUnblocked?.(); } }; }, [ active, ctx, onClose, onOpen ]); useEffect(() => { if (!closeOnEsc || !active || blocked) return void 0; const handleEscKey = (e) => { if (e.key === "Escape") hideContent(); }; window.addEventListener("keydown", handleEscKey); return () => { window.removeEventListener("keydown", handleEscKey); }; }, [ active, blocked, closeOnEsc, hideContent ]); useEffect(() => { if (!active) return void 0; const shouldSetOverflow = type === "modal"; const onScroll = (e) => { handleContentPosition(); handleVisibility(e); }; if (shouldSetOverflow) document.body.style.overflow = "hidden"; window.addEventListener("resize", handleContentPosition); window.addEventListener("scroll", onScroll, { passive: true }); return () => { if (shouldSetOverflow) document.body.style.overflow = ""; window.removeEventListener("resize", handleContentPosition); window.removeEventListener("scroll", onScroll); }; }, [ active, type, handleVisibility, handleContentPosition ]); useEffect(() => { if (!active || !parentContainer) return void 0; if (closeOn !== "hover") parentContainer.style.overflow = "hidden"; const onScroll = (e) => { handleContentPosition(); handleVisibility(e); }; parentContainer.addEventListener("scroll", onScroll, { passive: true }); return () => { parentContainer.style.overflow = ""; parentContainer.removeEventListener("scroll", onScroll); }; }, [ active, parentContainer, closeOn, handleContentPosition, handleVisibility ]); useEffect(() => { if (blocked || disabled) return void 0; if (openOn === "click" || [ "click", "clickOnTrigger", "clickOutsideContent" ].includes(closeOn)) window.addEventListener("click", handleClick); return () => { window.removeEventListener("click", handleClick); }; }, [ openOn, closeOn, blocked, disabled, handleClick ]); const hoverTimeoutRef = useRef(null); useEffect(() => { if (blocked || disabled || !(openOn === "hover" || closeOn === "hover")) return void 0; const trigger = triggerRef.current; const content = contentRef.current; const clearHoverTimeout = () => { if (hoverTimeoutRef.current != null) { clearTimeout(hoverTimeoutRef.current); hoverTimeoutRef.current = null; } }; const scheduleHide = () => { clearHoverTimeout(); hoverTimeoutRef.current = setTimeout(hideContent, 100); }; const onTriggerEnter = () => { clearHoverTimeout(); if (openOn === "hover" && !active) showContent(); }; const onTriggerLeave = () => { if (closeOn === "hover" && active) scheduleHide(); }; const onContentEnter = () => { clearHoverTimeout(); }; const onContentLeave = () => { if (closeOn === "hover" && active) scheduleHide(); }; if (trigger) { trigger.addEventListener("mouseenter", onTriggerEnter); trigger.addEventListener("mouseleave", onTriggerLeave); } if (content) { content.addEventListener("mouseenter", onContentEnter); content.addEventListener("mouseleave", onContentLeave); } return () => { clearHoverTimeout(); if (trigger) { trigger.removeEventListener("mouseenter", onTriggerEnter); trigger.removeEventListener("mouseleave", onTriggerLeave); } if (content) { content.removeEventListener("mouseenter", onContentEnter); content.removeEventListener("mouseleave", onContentLeave); } }; }, [ active, isContentLoaded, blocked, disabled, openOn, closeOn, showContent, hideContent ]); return { triggerRef, contentRef: useCallback((node) => { if (node) { contentRef.current = node; setContentLoaded(true); } }, []), active, align, alignX: innerAlignX, alignY: innerAlignY, showContent, hideContent, blocked, setBlocked, setUnblocked, Provider: Component }; }; //#endregion //#region src/Overlay/component.tsx /** * Overlay component that renders a trigger element and conditionally shows * content via a Portal. The trigger receives a ref and optional show/hide * callbacks; the content is positioned and managed by the useOverlay hook. * A context Provider wraps the content to support nested overlays (e.g., * a dropdown inside another dropdown) via blocked-state propagation. */ const IS_BROWSER = typeof window !== "undefined"; const Component$3 = ({ children, trigger, DOMLocation, triggerRefName = "ref", contentRefName = "ref", ...props }) => { const { active, triggerRef, contentRef, showContent, hideContent, align, alignX, alignY, Provider, ...ctx } = useOverlay(props); const { openOn, closeOn } = props; const passHandlers = useMemo(() => openOn === "manual" || closeOn === "manual" || closeOn === "clickOutsideContent", [openOn, closeOn]); return /* @__PURE__ */ jsxs(Fragment, { children: [render(trigger, { [triggerRefName]: triggerRef, active, ...passHandlers ? { showContent, hideContent } : {} }), IS_BROWSER && active && /* @__PURE__ */ jsx(Portal_default, { DOMLocation, children: /* @__PURE__ */ jsx(Provider, { ...ctx, children: render(children, { [contentRefName]: contentRef, active, align, alignX, alignY, ...passHandlers ? { showContent, hideContent } : {} }) }) })] }); }; const name$2 = `${PKG_NAME}/Overlay`; Component$3.displayName = name$2; Component$3.pkgName = PKG_NAME; Component$3.VITUS_LABS__COMPONENT = name$2; //#endregion //#region src/Overlay/index.ts var Overlay_default = Component$3; //#endregion //#region src/Text/styled.ts /** * Styled text primitive that inherits color, font-weight, and line-height * from its parent so it blends seamlessly into any context. Additional * styles can be injected via the responsive `extraStyles` prop processed * through makeItResponsive. */ const { styled, css, textComponent } = config; const styles = ({ css, theme: t }) => css` ${t.extraStyles && extendCss(t.extraStyles)}; `; var styled_default = styled(textComponent)` color: inherit; font-weight: inherit; line-height: 1; ${makeItResponsive({ key: "$text", styles, css, normalize: false })}; `; //#endregion //#region src/Text/component.tsx const Component$2 = forwardRef(({ paragraph, label, children, tag, css, ...props }, ref) => { const renderContent = (as = void 0) => /* @__PURE__ */ jsx(styled_default, { ref, as, $text: { extraStyles: css }, ...props, children: children ?? label }); let finalTag; if (paragraph) finalTag = "p"; else finalTag = tag; return renderContent(finalTag); }); const name$1 = `${PKG_NAME}/Text`; Component$2.displayName = name$1; Component$2.pkgName = PKG_NAME; Component$2.VITUS_LABS__COMPONENT = name$1; Component$2.isText = true; //#endregion //#region src/Text/index.ts var Text_default = Component$2; //#endregion //#region src/Util/component.tsx /** * Utility wrapper that injects className and/or style props into its * children without adding any DOM nodes of its own. Uses the core `render` * helper to clone children with the merged props. */ const Component$1 = ({ children, className, style }) => { const mergedClasses = useMemo(() => Array.isArray(className) ? className.join(" ") : className, [className]); const finalProps = {}; if (style) finalProps.style = style; if (mergedClasses) finalProps.className = mergedClasses; return render(children, finalProps); }; const name = `${PKG_NAME}/Util`; Component$1.displayName = name; Component$1.pkgName = PKG_NAME; Component$1.VITUS_LABS__COMPONENT = name; //#endregion //#region src/Util/index.ts var Util_default = Component$1; //#endregion export { Element_default as Element, List_default as List, Overlay_default as Overlay, Component as OverlayProvider, Portal_default as Portal, Provider, Text_default as Text, Util_default as Util, useOverlay }; //# sourceMappingURL=index.js.map