chore(root): prepare workspace for alpha (#276)

This commit is contained in:
rahim
2025-12-21 20:26:13 +11:00
committed by GitHub
parent 5ddf02278d
commit b89fef56dd
247 changed files with 1197 additions and 446 deletions
@@ -0,0 +1,46 @@
/**
* Converts a `NamedNodeMap` to a plain object.
*/
export function namedNodeMapToObject(namedNodeMap: NamedNodeMap): Record<string, string> {
const obj: Record<string, string> = {};
for (const attr of namedNodeMap) {
obj[attr.name] = attr.value;
}
return obj;
}
/**
* Sets multiple attributes on an element and handles boolean attributes appropriately.
*
* @param element - The element to set attributes on.
* @param attributes - The attributes to set.
*/
export function setAttributes(element: HTMLElement, attributes: Record<string, any>): void {
for (const [key, value] of Object.entries(attributes)) {
if (key === 'style' && typeof value === 'object') {
for (const [styleKey, styleValue] of Object.entries(value)) {
if (typeof styleValue === 'string') {
element.style.setProperty(toKebabCase(styleKey), styleValue);
} else if (styleValue == null) {
element.style.removeProperty(toKebabCase(styleKey));
}
}
} else {
if (typeof value === 'boolean') {
element.toggleAttribute(key, value);
} else if (value === undefined) {
element.removeAttribute(key);
} else {
element.setAttribute(key, value);
}
}
}
}
function toKebabCase(str: string): string {
return str
.replace(/([A-Z])/g, '-$1')
.toLowerCase();
}
@@ -0,0 +1,197 @@
/**
* Get the active element, accounting for Shadow DOM subtrees.
*
* @param root - The root node to search for the active element.
*/
export function activeElement(root: Document = document): Element | null {
let element = root.activeElement;
while (element?.shadowRoot?.activeElement != null) {
element = element.shadowRoot.activeElement;
}
return element;
}
/**
* Gets the document or shadow root of a node, not the node itself which can lead to bugs.
* https://developer.mozilla.org/en-US/docs/Web/API/Node/getRootNode#return_value
* @param node - The node to get the root node from.
*/
export function getDocumentOrShadowRoot(node: Node): Document | ShadowRoot | null {
const rootNode = node?.getRootNode?.();
if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
return rootNode;
}
return null;
}
export function getDocument(node?: Element | null): Document {
return node?.ownerDocument ?? document;
}
export function isElement(value: unknown): value is Element {
if (!hasWindow()) {
return false;
}
return value instanceof Element || value instanceof getWindow(value).Element;
}
export function contains(parent?: Element | null, child?: Element | null): boolean {
if (!parent || !child) {
return false;
}
const rootNode = child.getRootNode?.();
// First, attempt with faster native method
if (parent.contains(child)) {
return true;
}
// then fallback to custom implementation with Shadow DOM support
if (rootNode && isShadowRoot(rootNode)) {
let next = child;
while (next) {
if (parent === next) {
return true;
}
// @ts-expect-error - next.host is not defined in the type
next = next.parentNode || next.host;
}
}
// Give up, the result is false
return false;
}
export function getTarget(event: Event): EventTarget | null {
if ('composedPath' in event) {
return event.composedPath()[0] ?? null;
}
// TS thinks `event` is of type never as it assumes all browsers support
// `composedPath()`, but browsers without shadow DOM don't.
return (event as Event).target;
}
export function isShadowRoot(value: unknown): value is ShadowRoot {
if (!hasWindow() || typeof ShadowRoot === 'undefined') {
return false;
}
return value instanceof ShadowRoot || value instanceof getWindow(value).ShadowRoot;
}
function hasWindow() {
return typeof window !== 'undefined';
}
export function getWindow(node: any): typeof window {
return node?.ownerDocument?.defaultView || window;
}
export interface FloatingNodeType {
id: string;
parentId: string | null;
context: FloatingContext;
}
interface FloatingContext {
open: boolean;
}
export function getNodeChildren(
nodes: Array<FloatingNodeType>,
id: string | undefined,
onlyOpenChildren = true,
): Array<FloatingNodeType> {
const directChildren = nodes.filter(node => node.parentId === id && (!onlyOpenChildren || node.context?.open));
return directChildren.flatMap(child => [child, ...getNodeChildren(nodes, child.id, onlyOpenChildren)]);
}
export function getUntransformedBoundingRect(element: HTMLElement): DOMRect {
let el = element;
let left = 0;
let top = 0;
do {
left += el.offsetLeft;
top += el.offsetTop;
el = el.offsetParent as HTMLElement;
} while (el);
return {
x: left,
y: top,
left,
top,
bottom: top + element.offsetHeight,
right: left + element.offsetWidth,
width: element.offsetWidth,
height: element.offsetHeight,
} as DOMRect;
}
export function addTranslateToBoundingRect(rect: DOMRect, element: HTMLElement): DOMRect {
// Get translate from transform
const style = getWindow(element).getComputedStyle(element);
const translate = style.translate;
if (translate && translate !== 'none') {
const values = translate.split(' ');
// Parse translateX (can be px, %, etc)
const translateX = parseTranslateValue(values[0] ?? '0', element.offsetWidth);
const translateY = parseTranslateValue(values[1] ?? '0', element.offsetHeight);
return {
...rect,
left: rect.left + translateX,
top: rect.top + translateY,
};
}
return rect;
}
function parseTranslateValue(value: string, referenceSize: number): number {
if (value.endsWith('%')) {
return (Number.parseFloat(value) / 100) * referenceSize;
}
if (value.endsWith('px')) {
return Number.parseFloat(value);
}
// Handle other units like em, rem, etc if needed
return Number.parseFloat(value) || 0;
}
export function getInBoundsAdjustments(
popupRect: DOMRect,
containerRect: DOMRect,
collisionPadding: number,
): { x: number; y: number } {
const bounds = {
top: containerRect.top + collisionPadding,
right: containerRect.right - collisionPadding,
bottom: containerRect.bottom - collisionPadding,
left: containerRect.left + collisionPadding,
};
let x = 0;
let y = 0;
if (popupRect.left < bounds.left) {
x = bounds.left - popupRect.left;
} else if (popupRect.right > bounds.right) {
x = bounds.right - popupRect.right;
}
if (popupRect.top < bounds.top) {
y = bounds.top - popupRect.top;
} else if (popupRect.bottom > bounds.bottom) {
y = bounds.bottom - popupRect.bottom;
}
return { x, y };
}
@@ -0,0 +1,5 @@
export function isOutsideEvent(event: FocusEvent, container?: Element): boolean {
const containerElement = container || (event.currentTarget as Element);
const relatedTarget = event.relatedTarget as HTMLElement | null;
return !relatedTarget || !containerElement.contains(relatedTarget);
}
@@ -0,0 +1,5 @@
export * from './attributes';
export * from './element';
export * from './event';
export * from './safe-polygon';
export * from './shadow-dom';
@@ -0,0 +1,445 @@
import type { FloatingNodeType } from './element';
import { contains, getNodeChildren, getTarget, isElement } from './element';
type Point = [number, number];
type Polygon = Point[];
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
interface HandleClose {
(context: HandleCloseContext): (event: MouseEvent) => void;
__options?: SafePolygonOptions;
}
interface HandleCloseContext {
x: number;
y: number;
placement: string;
elements: Elements;
onClose: () => void;
nodeId?: string;
tree?: {
nodesRef: {
current: Array<FloatingNodeType>;
};
};
}
interface Elements {
domReference: HTMLElement;
floating: HTMLElement;
}
type Side = 'top' | 'right' | 'bottom' | 'left';
function clearTimeoutIfSet(timeoutRef: { current: number }) {
if (timeoutRef.current !== -1) {
clearTimeout(timeoutRef.current);
timeoutRef.current = -1;
}
}
function isPointInPolygon(point: Point, polygon: Polygon) {
const [x, y] = point;
let isInside = false;
const length = polygon.length;
for (let i = 0, j = length - 1; i < length; j = i++) {
const [xi, yi] = polygon[i] || [0, 0];
const [xj, yj] = polygon[j] || [0, 0];
const intersect
= (yi >= y) !== (yj >= y) && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) {
isInside = !isInside;
}
}
return isInside;
}
function isInside(point: Point, rect: Rect) {
return (
point[0] >= rect.x
&& point[0] <= rect.x + rect.width
&& point[1] >= rect.y
&& point[1] <= rect.y + rect.height
);
}
export interface SafePolygonOptions {
buffer?: number;
blockPointerEvents?: boolean;
requireIntent?: boolean;
}
/**
* Generates a safe polygon area that the user can traverse without closing the
* floating element once leaving the reference element.
* @see https://floating-ui.com/docs/useHover#safepolygon
*/
export function safePolygon(options: SafePolygonOptions = {}): HandleClose {
const {
buffer = 0.5,
blockPointerEvents = false,
requireIntent = true,
} = options;
const timeoutRef = { current: -1 };
let hasLanded = false;
let lastX: number | null = null;
let lastY: number | null = null;
let lastCursorTime
= typeof performance !== 'undefined' ? performance.now() : 0;
function getCursorSpeed(x: number, y: number): number | null {
const currentTime = performance.now();
const elapsedTime = currentTime - lastCursorTime;
if (lastX === null || lastY === null || elapsedTime === 0) {
lastX = x;
lastY = y;
lastCursorTime = currentTime;
return null;
}
const deltaX = x - lastX;
const deltaY = y - lastY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const speed = distance / elapsedTime; // px / ms
lastX = x;
lastY = y;
lastCursorTime = currentTime;
return speed;
}
const fn: HandleClose = ({
x,
y,
placement,
elements,
onClose,
nodeId,
tree,
}) => {
return function onMouseMove(event: MouseEvent) {
function close() {
clearTimeoutIfSet(timeoutRef);
onClose();
}
clearTimeoutIfSet(timeoutRef);
if (
!elements.domReference
|| !elements.floating
|| placement == null
|| x == null
|| y == null
) {
return;
}
const { clientX, clientY } = event;
const clientPoint: Point = [clientX, clientY];
const target = getTarget(event) as Element | null;
const isLeave = event.type === 'mouseleave';
const isOverFloatingEl = contains(elements.floating, target);
const isOverReferenceEl = contains(elements.domReference, target);
const refRect = elements.domReference.getBoundingClientRect();
const rect = elements.floating.getBoundingClientRect();
const side = placement.split('-')[0] as Side;
const cursorLeaveFromRight = x > rect.right - rect.width / 2;
const cursorLeaveFromBottom = y > rect.bottom - rect.height / 2;
const isOverReferenceRect = isInside(clientPoint, refRect);
const isFloatingWider = rect.width > refRect.width;
const isFloatingTaller = rect.height > refRect.height;
const left = (isFloatingWider ? refRect : rect).left;
const right = (isFloatingWider ? refRect : rect).right;
const top = (isFloatingTaller ? refRect : rect).top;
const bottom = (isFloatingTaller ? refRect : rect).bottom;
if (isOverFloatingEl) {
hasLanded = true;
if (!isLeave) {
return;
}
}
if (isOverReferenceEl) {
hasLanded = false;
}
if (isOverReferenceEl && !isLeave) {
hasLanded = true;
return;
}
// Prevent overlapping floating element from being stuck in an open-close
// loop: https://github.com/floating-ui/floating-ui/issues/1910
if (
isLeave
&& isElement(event.relatedTarget)
&& contains(elements.floating, event.relatedTarget)
) {
return;
}
// If any nested child is open, abort.
if (tree && getNodeChildren(tree.nodesRef.current, nodeId).length) {
return;
}
// If the pointer is leaving from the opposite side, the "buffer" logic
// creates a point where the floating element remains open, but should be
// ignored.
// A constant of 1 handles floating point rounding errors.
if (
(side === 'top' && y >= refRect.bottom - 1)
|| (side === 'bottom' && y <= refRect.top + 1)
|| (side === 'left' && x >= refRect.right - 1)
|| (side === 'right' && x <= refRect.left + 1)
) {
return close();
}
// Ignore when the cursor is within the rectangular trough between the
// two elements. Since the triangle is created from the cursor point,
// which can start beyond the ref element's edge, traversing back and
// forth from the ref to the floating element can cause it to close. This
// ensures it always remains open in that case.
let rectPoly: Point[] = [];
switch (side) {
case 'top':
rectPoly = [
[left, refRect.top + 1],
[left, rect.bottom - 1],
[right, rect.bottom - 1],
[right, refRect.top + 1],
];
break;
case 'bottom':
rectPoly = [
[left, rect.top + 1],
[left, refRect.bottom - 1],
[right, refRect.bottom - 1],
[right, rect.top + 1],
];
break;
case 'left':
rectPoly = [
[rect.right - 1, bottom],
[rect.right - 1, top],
[refRect.left + 1, top],
[refRect.left + 1, bottom],
];
break;
case 'right':
rectPoly = [
[refRect.right - 1, bottom],
[refRect.right - 1, top],
[rect.left + 1, top],
[rect.left + 1, bottom],
];
break;
default:
rectPoly = [];
break;
}
function getPolygon([x, y]: Point): Array<Point> {
switch (side) {
case 'top': {
const cursorPointOne: Point = [
isFloatingWider
? x + buffer / 2
: cursorLeaveFromRight
? x + buffer * 4
: x - buffer * 4,
y + buffer + 1,
];
const cursorPointTwo: Point = [
isFloatingWider
? x - buffer / 2
: cursorLeaveFromRight
? x + buffer * 4
: x - buffer * 4,
y + buffer + 1,
];
const commonPoints: [Point, Point] = [
[
rect.left,
cursorLeaveFromRight
? rect.bottom - buffer
: isFloatingWider
? rect.bottom - buffer
: rect.top,
],
[
rect.right,
cursorLeaveFromRight
? isFloatingWider
? rect.bottom - buffer
: rect.top
: rect.bottom - buffer,
],
];
return [cursorPointOne, cursorPointTwo, ...commonPoints];
}
case 'bottom': {
const cursorPointOne: Point = [
isFloatingWider
? x + buffer / 2
: cursorLeaveFromRight
? x + buffer * 4
: x - buffer * 4,
y - buffer,
];
const cursorPointTwo: Point = [
isFloatingWider
? x - buffer / 2
: cursorLeaveFromRight
? x + buffer * 4
: x - buffer * 4,
y - buffer,
];
const commonPoints: [Point, Point] = [
[
rect.left,
cursorLeaveFromRight
? rect.top + buffer
: isFloatingWider
? rect.top + buffer
: rect.bottom,
],
[
rect.right,
cursorLeaveFromRight
? isFloatingWider
? rect.top + buffer
: rect.bottom
: rect.top + buffer,
],
];
return [cursorPointOne, cursorPointTwo, ...commonPoints];
}
case 'left': {
const cursorPointOne: Point = [
x + buffer + 1,
isFloatingTaller
? y + buffer / 2
: cursorLeaveFromBottom
? y + buffer * 4
: y - buffer * 4,
];
const cursorPointTwo: Point = [
x + buffer + 1,
isFloatingTaller
? y - buffer / 2
: cursorLeaveFromBottom
? y + buffer * 4
: y - buffer * 4,
];
const commonPoints: [Point, Point] = [
[
cursorLeaveFromBottom
? rect.right - buffer
: isFloatingTaller
? rect.right - buffer
: rect.left,
rect.top,
],
[
cursorLeaveFromBottom
? isFloatingTaller
? rect.right - buffer
: rect.left
: rect.right - buffer,
rect.bottom,
],
];
return [...commonPoints, cursorPointOne, cursorPointTwo];
}
case 'right': {
const cursorPointOne: Point = [
x - buffer,
isFloatingTaller
? y + buffer / 2
: cursorLeaveFromBottom
? y + buffer * 4
: y - buffer * 4,
];
const cursorPointTwo: Point = [
x - buffer,
isFloatingTaller
? y - buffer / 2
: cursorLeaveFromBottom
? y + buffer * 4
: y - buffer * 4,
];
const commonPoints: [Point, Point] = [
[
cursorLeaveFromBottom
? rect.left + buffer
: isFloatingTaller
? rect.left + buffer
: rect.right,
rect.top,
],
[
cursorLeaveFromBottom
? isFloatingTaller
? rect.left + buffer
: rect.right
: rect.left + buffer,
rect.bottom,
],
];
return [cursorPointOne, cursorPointTwo, ...commonPoints];
}
default:
return [[x, y]];
}
}
if (isPointInPolygon([clientX, clientY], rectPoly)) {
return;
}
if (hasLanded && !isOverReferenceRect) {
return close();
}
if (!isLeave && requireIntent) {
const cursorSpeed = getCursorSpeed(event.clientX, event.clientY);
const cursorSpeedThreshold = 0.1;
if (cursorSpeed !== null && cursorSpeed < cursorSpeedThreshold) {
return close();
}
}
if (!isPointInPolygon([clientX, clientY], getPolygon([x, y]))) {
close();
} else if (!hasLanded && requireIntent) {
timeoutRef.current = window.setTimeout(close, 40);
}
};
};
fn.__options = {
blockPointerEvents,
};
return fn;
}
@@ -0,0 +1,12 @@
/**
* Utility function to check if a root node contains a child node across shadow DOM boundaries.
*/
export function containsComposedNode(rootNode: Node, childNode: Node): boolean {
if (!rootNode || !childNode) return false;
if (rootNode?.contains(childNode)) return true;
const childRootNode = childNode.getRootNode();
if (childRootNode && 'host' in childRootNode && childRootNode.host) {
return containsComposedNode(rootNode, childRootNode.host as Node);
}
return false;
}
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["./**/*.ts"]
}