@vitus-labs/elements
Version:
Most basic react reusable components
1,333 lines (1,290 loc) • 40.1 kB
JavaScript
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