@flood-md/react 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ export type Side = "top" | "bottom" | "left" | "right";
2
+ export type Align = "start" | "center" | "end";
3
+ type Rect = {
4
+ left: number;
5
+ top: number;
6
+ right: number;
7
+ bottom: number;
8
+ width: number;
9
+ height: number;
10
+ };
11
+ /** Pure collision handling in layout-viewport coordinates. */
12
+ export declare function placeOverlay(anchor: Rect, size: {
13
+ width: number;
14
+ height: number;
15
+ }, viewport: {
16
+ left: number;
17
+ top: number;
18
+ width: number;
19
+ height: number;
20
+ }, side: Side, align: Align, gap: number, rtl?: boolean): {
21
+ left: number;
22
+ top: number;
23
+ side: Side;
24
+ };
25
+ export declare function tabbables(root: HTMLElement): HTMLElement[];
26
+ type Layer = {
27
+ panel: HTMLElement;
28
+ anchor: () => HTMLElement | null;
29
+ close: () => void;
30
+ outside?: () => void;
31
+ modal?: boolean;
32
+ tooltip?: boolean;
33
+ };
34
+ export declare function registerLayer(document: Document, layer: Layer): () => void;
35
+ export declare function lockScroll(document: Document): () => void;
36
+ export declare function positionLayer(anchor: HTMLElement, panel: HTMLElement, side: Side, align: Align): void;
37
+ export declare function returnFocus(panel: HTMLElement, anchor: HTMLElement | null): void;
38
+ export {};
@@ -0,0 +1,110 @@
1
+ /** Pure collision handling in layout-viewport coordinates. */
2
+ export function placeOverlay(anchor, size, viewport, side, align, gap, rtl = false) {
3
+ const edge = { left: viewport.left + gap, right: viewport.left + viewport.width - gap, top: viewport.top + gap, bottom: viewport.top + viewport.height - gap };
4
+ const space = { top: anchor.top - edge.top, bottom: edge.bottom - anchor.bottom, left: anchor.left - edge.left, right: edge.right - anchor.right };
5
+ const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" };
6
+ const needed = (side === "top" || side === "bottom" ? size.height : size.width) + gap;
7
+ if (space[side] < needed && space[opposite[side]] > space[side])
8
+ side = opposite[side];
9
+ const horizontal = side === "top" || side === "bottom";
10
+ const resolved = horizontal && rtl ? (align === "start" ? "end" : align === "end" ? "start" : align) : align;
11
+ let left = horizontal ? (resolved === "start" ? anchor.left : resolved === "end" ? anchor.right - size.width : anchor.left + (anchor.width - size.width) / 2) : side === "left" ? anchor.left - size.width - gap : anchor.right + gap;
12
+ let top = horizontal ? (side === "top" ? anchor.top - size.height - gap : anchor.bottom + gap) : (resolved === "start" ? anchor.top : resolved === "end" ? anchor.bottom - size.height : anchor.top + (anchor.height - size.height) / 2);
13
+ left = Math.max(edge.left, Math.min(left, edge.right - size.width));
14
+ top = Math.max(edge.top, Math.min(top, edge.bottom - size.height));
15
+ return { left, top, side };
16
+ }
17
+ export function tabbables(root) {
18
+ const candidates = Array.from(root.querySelectorAll('a[href],button,input,select,textarea,[tabindex],[contenteditable]:not([contenteditable="false"]),details > summary:first-of-type,audio[controls],video[controls],iframe')).filter(node => {
19
+ // Editing roots and native media may report -1 without an explicit tabindex
20
+ // while still participating in sequential focus. Nested editing hosts do not.
21
+ const implicit = !node.hasAttribute("tabindex") && ((node.isContentEditable && !node.parentElement?.isContentEditable) || node.matches("audio[controls],video[controls]"));
22
+ return (node.tabIndex >= 0 || implicit) && !node.matches(':disabled,input[type="hidden"]') && !node.closest('[hidden],[inert]') && node.getClientRects().length > 0 && getComputedStyle(node).visibility !== "hidden";
23
+ });
24
+ return candidates.filter(node => {
25
+ if (node.localName !== "input" || node.type !== "radio" || !node.name)
26
+ return true;
27
+ const radio = node;
28
+ const group = candidates.filter((other) => other.localName === "input" && other.type === "radio" && other.name === radio.name && other.form === radio.form);
29
+ return node === (group.find(other => other.checked) ?? group[0]);
30
+ }).sort((a, b) => (a.tabIndex > 0 ? a.tabIndex : Infinity) - (b.tabIndex > 0 ? b.tabIndex : Infinity));
31
+ }
32
+ const stacks = new WeakMap();
33
+ export function registerLayer(document, layer) {
34
+ let layers = stacks.get(document);
35
+ if (!layers) {
36
+ layers = [];
37
+ stacks.set(document, layers);
38
+ }
39
+ layers.push(layer);
40
+ const top = () => layers.filter(item => item.panel.isConnected && (item.panel.localName === "dialog" ? item.panel.open : item.panel.matches(":popover-open"))).at(-1);
41
+ const key = (event) => { if (event.defaultPrevented || event.key !== "Escape" || top() !== layer)
42
+ return; event.preventDefault(); event.stopPropagation(); layer.close(); };
43
+ const pointer = (event) => {
44
+ if (event.defaultPrevented || top() !== layer)
45
+ return;
46
+ const path = event.composedPath();
47
+ if (layer.modal) {
48
+ const rect = layer.panel.getBoundingClientRect();
49
+ if (event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom)
50
+ return;
51
+ }
52
+ else if (path.includes(layer.panel) || path.includes(layer.anchor()))
53
+ return;
54
+ (layer.outside ?? layer.close)();
55
+ };
56
+ const focus = (event) => {
57
+ if (top() !== layer || layer.modal)
58
+ return;
59
+ const target = event.target;
60
+ if (!layer.panel.contains(target) && !layer.anchor()?.contains(target))
61
+ layer.close();
62
+ };
63
+ document.addEventListener("keydown", key);
64
+ document.addEventListener("pointerdown", pointer);
65
+ document.addEventListener("focusin", focus);
66
+ return () => { const index = layers.indexOf(layer); if (index >= 0)
67
+ layers.splice(index, 1); document.removeEventListener("keydown", key); document.removeEventListener("pointerdown", pointer); document.removeEventListener("focusin", focus); };
68
+ }
69
+ const locks = new WeakMap();
70
+ export function lockScroll(document) {
71
+ const style = document.documentElement.style;
72
+ let lock = locks.get(document);
73
+ if (!lock) {
74
+ const reserve = (document.defaultView?.innerWidth ?? 0) - document.documentElement.clientWidth > 0;
75
+ lock = { count: 0, overflow: style.getPropertyValue("overflow"), overflowPriority: style.getPropertyPriority("overflow"), gutter: style.getPropertyValue("scrollbar-gutter"), gutterPriority: style.getPropertyPriority("scrollbar-gutter") };
76
+ locks.set(document, lock);
77
+ style.setProperty("overflow", "hidden", "important");
78
+ if (reserve)
79
+ style.setProperty("scrollbar-gutter", "stable", "important");
80
+ }
81
+ lock.count++;
82
+ return () => { if (--lock.count !== 0)
83
+ return; for (const [key, value, priority] of [["overflow", lock.overflow, lock.overflowPriority], ["scrollbar-gutter", lock.gutter, lock.gutterPriority]]) {
84
+ if (value)
85
+ style.setProperty(key, value, priority);
86
+ else
87
+ style.removeProperty(key);
88
+ } locks.delete(document); };
89
+ }
90
+ export function positionLayer(anchor, panel, side, align) {
91
+ const view = panel.ownerDocument.defaultView;
92
+ const visual = view.visualViewport;
93
+ const viewport = { left: visual?.offsetLeft ?? 0, top: visual?.offsetTop ?? 0, width: visual?.width ?? view.innerWidth, height: visual?.height ?? view.innerHeight };
94
+ const gap = parseFloat(getComputedStyle(panel).columnGap) || 8;
95
+ panel.style.setProperty("--flood-overlay-available-width", `${Math.max(0, viewport.width - 2 * gap)}px`);
96
+ panel.style.setProperty("--flood-overlay-available-height", `${Math.max(0, viewport.height - 2 * gap)}px`);
97
+ const position = placeOverlay(anchor.getBoundingClientRect(), panel.getBoundingClientRect(), viewport, side, align, gap, getComputedStyle(anchor).direction === "rtl");
98
+ const left = `${position.left}px`, top = `${position.top}px`;
99
+ if (panel.style.left !== left)
100
+ panel.style.left = left;
101
+ if (panel.style.top !== top)
102
+ panel.style.top = top;
103
+ panel.dataset.side = position.side;
104
+ panel.style.visibility = "visible";
105
+ }
106
+ export function returnFocus(panel, anchor) {
107
+ const active = panel.ownerDocument.activeElement;
108
+ if (anchor?.isConnected && !anchor.matches(":disabled") && (panel.contains(active) || active === panel.ownerDocument.body))
109
+ anchor.focus({ preventScroll: true });
110
+ }
@@ -0,0 +1,7 @@
1
+ import type { TooltipProps, PopoverProps, DialogProps } from "./index.js";
2
+ type AnchoredProps = (TooltipProps | PopoverProps) & {
3
+ kind: "tooltip" | "popover";
4
+ };
5
+ export declare const AnchoredOverlay: import("react").ForwardRefExoticComponent<AnchoredProps & import("react").RefAttributes<HTMLDivElement>>;
6
+ export declare const ModalOverlay: import("react").ForwardRefExoticComponent<DialogProps & import("react").RefAttributes<HTMLDialogElement>>;
7
+ export {};
@@ -0,0 +1,179 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ import { IconButton } from "./index.js";
4
+ import { positionLayer, registerLayer, returnFocus, lockScroll, tabbables } from "./overlay-runtime.js";
5
+ import { mergeDescriptions } from "./field-state.js";
6
+ const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
7
+ function useCurrent(value) { const ref = useRef(value); useBrowserLayoutEffect(() => { ref.current = value; }); return ref; }
8
+ function focusContent(panel, preferred) {
9
+ const available = (node) => Boolean(node?.isConnected && panel.contains(node) && !node.matches(":disabled") && !node.closest("[hidden],[inert]") && node.getClientRects().length && getComputedStyle(node).visibility !== "hidden");
10
+ const candidates = [preferred, ...panel.querySelectorAll('[autofocus],[data-flood-autofocus]'), ...tabbables(panel.querySelector(".flood-overlay-body") ?? panel), ...tabbables(panel), panel];
11
+ for (const target of candidates) {
12
+ if (!available(target))
13
+ continue;
14
+ const previous = panel.ownerDocument.activeElement;
15
+ target.focus({ preventScroll: true });
16
+ // A host focus handler may intentionally redirect focus elsewhere.
17
+ if (panel.ownerDocument.activeElement === target || panel.ownerDocument.activeElement !== previous)
18
+ return;
19
+ }
20
+ }
21
+ function OverlayClose({ label, onClick }) {
22
+ return _jsx(IconButton, { label: label, size: "sm", variant: "subtle", className: "flood-overlay-close", onClick: onClick, children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "m4 4 16 16M20 4 4 20" }) }) });
23
+ }
24
+ export const AnchoredOverlay = forwardRef(function AnchoredOverlay(allProps, forwardedRef) {
25
+ const { kind, trigger, side = "bottom", align = "start", disabled = false, id: explicitId, className, style, ...native } = allProps;
26
+ const pop = allProps, tip = allProps;
27
+ const [local, setLocal] = useState(kind === "popover" ? Boolean(pop.defaultOpen) : false);
28
+ const open = kind === "tooltip" ? local : pop.open ?? local;
29
+ const anchor = useRef(null), panel = useRef(null), generated = useId();
30
+ const id = explicitId ?? `flood-overlay-${generated}`, setAnchor = useCallback((node) => { anchor.current = node; }, []);
31
+ const hovered = useRef(false), overPanel = useRef(false), focused = useRef(false), suppressed = useRef(false);
32
+ const timer = useRef(undefined);
33
+ const clear = () => { clearTimeout(timer.current); timer.current = undefined; };
34
+ const request = (next) => { if (kind === "tooltip" || pop.open === undefined)
35
+ setLocal(next); if (kind === "popover")
36
+ pop.onOpenChange?.(next); };
37
+ const close = () => { clear(); if (kind === "tooltip") {
38
+ suppressed.current = true;
39
+ overPanel.current = false;
40
+ } request(false); };
41
+ const current = useCurrent({ close, request, disabled, open, initialFocusRef: pop.initialFocusRef });
42
+ useImperativeHandle(forwardedRef, () => panel.current, []);
43
+ useEffect(() => () => clearTimeout(timer.current), []);
44
+ useEffect(() => { if (kind === "tooltip" && disabled) {
45
+ clear();
46
+ setLocal(false);
47
+ } }, [disabled, kind]);
48
+ const leave = () => { clear(); if (!hovered.current && !overPanel.current && !focused.current) {
49
+ suppressed.current = false;
50
+ timer.current = setTimeout(() => request(false), 120);
51
+ } };
52
+ const enter = (immediate = false) => { clear(); if (disabled || suppressed.current)
53
+ return; if (immediate)
54
+ setLocal(true);
55
+ else
56
+ timer.current = setTimeout(() => setLocal(true), Number.isFinite(tip.delay) ? Math.max(0, tip.delay ?? 300) : 300); };
57
+ useBrowserLayoutEffect(() => {
58
+ if (!open || !panel.current)
59
+ return;
60
+ const element = panel.current, document = element.ownerDocument, view = document.defaultView;
61
+ let alive = true, unregister = () => { }, frame = 0;
62
+ const start = () => {
63
+ if (!alive || !anchor.current?.isConnected)
64
+ return;
65
+ const parent = anchor.current.closest("dialog");
66
+ if (parent && !parent.open) {
67
+ frame = view.requestAnimationFrame(start);
68
+ return;
69
+ }
70
+ if (typeof element.showPopover !== "function")
71
+ throw new Error("Flood overlays require the native Popover API");
72
+ element.style.visibility = "hidden";
73
+ element.showPopover();
74
+ positionLayer(anchor.current, element, side, align);
75
+ unregister = registerLayer(document, { panel: element, anchor: () => anchor.current, close: () => current.current.close(), tooltip: kind === "tooltip" });
76
+ if (kind === "popover")
77
+ focusContent(element, current.current.initialFocusRef?.current);
78
+ const update = () => { if (!alive)
79
+ return; if (anchor.current?.isConnected)
80
+ positionLayer(anchor.current, element, side, align); frame = view.requestAnimationFrame(update); };
81
+ frame = view.requestAnimationFrame(update);
82
+ };
83
+ start();
84
+ const toggle = (event) => {
85
+ if (!alive || event.newState !== "closed" || element.matches(":popover-open") || !current.current.open)
86
+ return;
87
+ current.current.request(false);
88
+ queueMicrotask(() => { if (alive && current.current.open && element.isConnected && !element.matches(":popover-open") && anchor.current?.isConnected) {
89
+ element.showPopover();
90
+ positionLayer(anchor.current, element, side, align);
91
+ if (kind === "popover")
92
+ focusContent(element, current.current.initialFocusRef?.current);
93
+ } });
94
+ };
95
+ element.addEventListener("toggle", toggle);
96
+ return () => { alive = false; view.cancelAnimationFrame(frame); unregister(); element.removeEventListener("toggle", toggle); if (kind === "popover")
97
+ returnFocus(element, anchor.current); if (element.matches(":popover-open"))
98
+ element.hidePopover(); };
99
+ }, [open, side, align, kind]);
100
+ const triggerProps = { ref: setAnchor, type: "button", ...(kind === "tooltip" ? {
101
+ "aria-describedby": disabled ? undefined : id,
102
+ onPointerEnter: event => { if (event.pointerType === "touch")
103
+ return; hovered.current = true; enter(); },
104
+ onPointerLeave: () => { hovered.current = false; leave(); }, onFocus: () => { focused.current = true; enter(true); }, onBlur: () => { focused.current = false; leave(); },
105
+ } : { disabled, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": id, onClick: () => request(!open) }) };
106
+ // Remove component-only props before forwarding native div attributes.
107
+ const { title: unusedTitle, content: unusedContent, children: unusedChildren, open: unusedOpen, defaultOpen: unusedDefault, onOpenChange: unusedChange, delay: unusedDelay, closeLabel: unusedClose, initialFocusRef: unusedFocus, ...attributes } = native;
108
+ return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx("div", { ...attributes, ref: panel, id: id, popover: "manual", role: kind === "tooltip" ? "tooltip" : "dialog", "aria-labelledby": kind === "popover" ? `${id}-title` : undefined, tabIndex: kind === "popover" ? -1 : undefined, className: ["flood-overlay-panel", kind === "tooltip" ? "flood-tooltip" : "flood-popover", className].filter(Boolean).join(" "), style: style, onPointerEnter: event => { attributes.onPointerEnter?.(event); if (kind === "tooltip") {
109
+ overPanel.current = true;
110
+ clear();
111
+ } }, onPointerLeave: event => { attributes.onPointerLeave?.(event); if (kind === "tooltip") {
112
+ overPanel.current = false;
113
+ leave();
114
+ } }, children: kind === "tooltip" ? tip.content : _jsxs(_Fragment, { children: [_jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: pop.title }), _jsx(OverlayClose, { label: pop.closeLabel ?? "Close", onClick: close })] }), open && _jsx("div", { className: "flood-overlay-body", children: pop.children })] }) })] });
115
+ });
116
+ export const ModalOverlay = forwardRef(function ModalOverlay({ title, description, trigger, children, open: controlled, defaultOpen = false, onOpenChange, disabled = false, closeLabel = "Close", closeOnOutsideClick = true, initialFocusRef, id: explicitId, className, onKeyDown, "aria-describedby": describedBy, ...props }, forwardedRef) {
117
+ const [local, setLocal] = useState(defaultOpen), open = controlled ?? local;
118
+ const dialog = useRef(null), anchor = useRef(null), generated = useId(), id = explicitId ?? `flood-dialog-${generated}`;
119
+ const request = (next) => { if (controlled === undefined)
120
+ setLocal(next); onOpenChange?.(next); };
121
+ const current = useCurrent({ open, request, closeOnOutsideClick, initialFocusRef });
122
+ const setAnchor = useCallback((node) => { anchor.current = node; }, []);
123
+ useImperativeHandle(forwardedRef, () => dialog.current, []);
124
+ useBrowserLayoutEffect(() => {
125
+ if (!open || !dialog.current)
126
+ return;
127
+ const element = dialog.current, document = element.ownerDocument;
128
+ const view = document.defaultView;
129
+ let alive = true, frame = 0, dispose = () => { };
130
+ const start = () => {
131
+ if (!alive)
132
+ return;
133
+ const parent = element.parentElement?.closest("dialog");
134
+ if (parent && !parent.open) {
135
+ frame = view.requestAnimationFrame(start);
136
+ return;
137
+ }
138
+ if (typeof element.showModal !== "function")
139
+ throw new Error("Flood Dialog requires native showModal");
140
+ const previous = document.activeElement;
141
+ element.showModal();
142
+ const unlock = lockScroll(document);
143
+ const unregister = registerLayer(document, { panel: element, anchor: () => anchor.current, modal: true, close: () => current.current.request(false), outside: () => { if (current.current.closeOnOutsideClick)
144
+ current.current.request(false); } });
145
+ // Escape always closes, even when outside dismissal is disabled.
146
+ const cancel = (event) => { event.preventDefault(); current.current.request(false); };
147
+ const closed = () => { if (element.open || !current.current.open)
148
+ return; current.current.request(false); queueMicrotask(() => { if (current.current.open && element.isConnected && !element.open) {
149
+ element.showModal();
150
+ focusContent(element, current.current.initialFocusRef?.current);
151
+ } }); };
152
+ element.addEventListener("cancel", cancel);
153
+ element.addEventListener("close", closed);
154
+ focusContent(element, current.current.initialFocusRef?.current);
155
+ dispose = () => { unregister(); unlock(); element.removeEventListener("cancel", cancel); element.removeEventListener("close", closed); if (element.open)
156
+ element.close(); returnFocus(element, anchor.current ?? previous); };
157
+ };
158
+ start();
159
+ return () => { alive = false; view.cancelAnimationFrame(frame); dispose(); };
160
+ }, [open]);
161
+ return _jsxs(_Fragment, { children: [trigger({ ref: setAnchor, type: "button", disabled, "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": id, onClick: () => request(true) }), _jsxs("dialog", { ...props, ref: dialog, id: id, "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": mergeDescriptions(describedBy, Boolean(description) && `${id}-description`), className: ["flood-dialog", className].filter(Boolean).join(" "), onKeyDown: event => {
162
+ onKeyDown?.(event);
163
+ if (event.defaultPrevented || event.key !== "Tab")
164
+ return;
165
+ const items = tabbables(event.currentTarget), first = items[0], last = items.at(-1), active = event.currentTarget.ownerDocument.activeElement;
166
+ if (!first) {
167
+ event.preventDefault();
168
+ return;
169
+ }
170
+ if (event.shiftKey && (active === first || !items.includes(active))) {
171
+ event.preventDefault();
172
+ last?.focus();
173
+ }
174
+ else if (!event.shiftKey && (active === last || !items.includes(active))) {
175
+ event.preventDefault();
176
+ first.focus();
177
+ }
178
+ }, children: [_jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: title }), _jsx(OverlayClose, { label: closeLabel, onClick: () => request(false) })] }), description && _jsx("p", { id: `${id}-description`, className: "flood-overlay-description", children: description }), open && _jsx("div", { className: "flood-overlay-body", children: children })] })] });
179
+ });
@@ -0,0 +1 @@
1
+ export declare function pageWindow(page: number, count: number, siblings: 0 | 1 | 2): Array<number | "gap-start" | "gap-end">;
@@ -0,0 +1,15 @@
1
+ export function pageWindow(page, count, siblings) {
2
+ if (!Number.isSafeInteger(count) || count < 1 || !Number.isSafeInteger(page) || page < 1 || page > count)
3
+ throw new RangeError("Flood Pagination requires a valid page within a positive safe-integer pageCount");
4
+ if (![0, 1, 2].includes(siblings))
5
+ throw new RangeError("Flood Pagination siblingCount must be 0, 1 or 2");
6
+ const range = (from, to) => Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => from + index);
7
+ const budget = 2 * siblings + 5;
8
+ if (count <= budget)
9
+ return range(1, count);
10
+ if (page <= siblings + 3)
11
+ return [...range(1, 2 * siblings + 3), "gap-end", count];
12
+ if (page >= count - siblings - 2)
13
+ return [1, "gap-start", ...range(count - 2 * siblings - 2, count)];
14
+ return [1, "gap-start", ...range(page - siblings, page + siblings), "gap-end", count];
15
+ }
@@ -0,0 +1,3 @@
1
+ import type { ScrollAreaProps } from "./index.js";
2
+ /** Original overlay chrome; the viewport itself remains a browser-native scroll container. */
3
+ export declare const OverlayScrollArea: import("react").ForwardRefExoticComponent<ScrollAreaProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,235 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
+ const rtlModes = new WeakMap();
4
+ const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
+ function rtlMode(document) {
6
+ const saved = rtlModes.get(document);
7
+ if (saved)
8
+ return saved;
9
+ const probe = document.createElement("div"), content = document.createElement("div");
10
+ probe.dir = "rtl";
11
+ probe.style.cssText = "position:fixed;left:-10000px;width:4px;height:1px;overflow:scroll;visibility:hidden";
12
+ content.style.width = "8px";
13
+ probe.appendChild(content);
14
+ document.body.appendChild(probe);
15
+ let mode = "default";
16
+ if (probe.scrollLeft === 0) {
17
+ probe.scrollLeft = 1;
18
+ mode = probe.scrollLeft === 0 ? "negative" : "reverse";
19
+ }
20
+ probe.remove();
21
+ rtlModes.set(document, mode);
22
+ return mode;
23
+ }
24
+ function horizontalOffset(element, rtl) {
25
+ const max = element.scrollWidth - element.clientWidth;
26
+ const value = !rtl ? element.scrollLeft : rtlMode(element.ownerDocument) === "negative" ? max + element.scrollLeft : rtlMode(element.ownerDocument) === "reverse" ? max - element.scrollLeft : element.scrollLeft;
27
+ return Math.max(0, Math.min(max, value));
28
+ }
29
+ function scrollToOffset(element, axis, value) {
30
+ if (axis === "vertical") {
31
+ element.scrollTop = value;
32
+ return;
33
+ }
34
+ const max = element.scrollWidth - element.clientWidth;
35
+ const next = Math.max(0, Math.min(max, value));
36
+ element.scrollLeft = getComputedStyle(element).direction !== "rtl" ? next : rtlMode(element.ownerDocument) === "negative" ? next - max : rtlMode(element.ownerDocument) === "reverse" ? max - next : next;
37
+ }
38
+ const emptyBar = { visible: false, length: 0, thumb: 0, position: 0, max: 0, offset: 0, left: 0, top: 0, size: 0 };
39
+ const empty = { enhanced: false, vertical: emptyBar, horizontal: emptyBar };
40
+ const sameBar = (a, b) => a.visible === b.visible && a.length === b.length && a.thumb === b.thumb && a.position === b.position && a.max === b.max && a.offset === b.offset && a.left === b.left && a.top === b.top && a.size === b.size;
41
+ /** Keep the whole rail clear of rounded corners that actually clip this viewport. */
42
+ function cornerBounds(node, axis, crossStart, crossEnd, start, end, inset) {
43
+ const viewport = node.getBoundingClientRect();
44
+ const scaleX = node.offsetWidth ? viewport.width / node.offsetWidth : 1;
45
+ const scaleY = node.offsetHeight ? viewport.height / node.offsetHeight : 1;
46
+ if (!scaleX || !scaleY)
47
+ return { start, end };
48
+ for (let ancestor = node; ancestor; ancestor = ancestor.parentElement) {
49
+ const css = getComputedStyle(ancestor);
50
+ if (ancestor !== node && ![css.overflowX, css.overflowY].some(value => ["hidden", "clip", "auto", "scroll"].includes(value)))
51
+ continue;
52
+ const radii = [css.borderTopLeftRadius, css.borderTopRightRadius, css.borderBottomRightRadius, css.borderBottomLeftRadius];
53
+ if (!radii.some(value => value.split(/\s+/).some(part => parseFloat(part) > 0)))
54
+ continue;
55
+ const rect = ancestor.getBoundingClientRect();
56
+ const left = (rect.left - viewport.left) / scaleX - node.clientLeft;
57
+ const top = (rect.top - viewport.top) / scaleY - node.clientTop;
58
+ const width = rect.width / scaleX, height = rect.height / scaleY;
59
+ const radius = (value) => {
60
+ const [x, y = x] = value.split(/\s+/);
61
+ const resolve = (part, dimension) => part.endsWith("%") ? parseFloat(part) * dimension / 100 : parseFloat(part) || 0;
62
+ return { x: resolve(x, width), y: resolve(y, height) };
63
+ };
64
+ const corners = radii.map(radius);
65
+ // CSS scales overlapping elliptical radii together, including asymmetric corners.
66
+ const [tl, tr, br, bl] = corners;
67
+ const factor = Math.min(1, width / (tl.x + tr.x || 1), width / (bl.x + br.x || 1), height / (tl.y + bl.y || 1), height / (tr.y + br.y || 1));
68
+ corners.forEach(corner => { corner.x *= factor; corner.y *= factor; });
69
+ const isVertical = axis === "vertical";
70
+ const crossLow = isVertical ? left : top, crossHigh = crossLow + (isVertical ? width : height);
71
+ const mainLow = isVertical ? top : left, mainHigh = mainLow + (isVertical ? height : width);
72
+ const cap = (pair) => Math.max(0, ...pair.map((corner, side) => {
73
+ const crossRadius = isVertical ? corner.x : corner.y;
74
+ const nearCorner = side === 0 ? crossStart < crossLow + crossRadius + inset : crossEnd > crossHigh - crossRadius - inset;
75
+ return crossRadius > 0 && nearCorner && crossEnd > crossLow && crossStart < crossHigh ? isVertical ? corner.y : corner.x : 0;
76
+ }));
77
+ const startCap = cap(isVertical ? [tl, tr] : [tl, bl]);
78
+ const endCap = cap(isVertical ? [bl, br] : [tr, br]);
79
+ if (startCap)
80
+ start = Math.max(start, mainLow + startCap + inset);
81
+ if (endCap)
82
+ end = Math.min(end, mainHigh - endCap - inset);
83
+ }
84
+ return { start, end: Math.max(start, end) };
85
+ }
86
+ /** Original overlay chrome; the viewport itself remains a browser-native scroll container. */
87
+ export const OverlayScrollArea = forwardRef(function OverlayScrollArea({ orientation = "vertical", contentInset = "none", children, className, style, tabIndex = 0, role, onFocus, ...props }, forwardedRef) {
88
+ const id = useId();
89
+ const viewport = useRef(null), content = useRef(null);
90
+ const vertical = useRef(null), horizontal = useRef(null);
91
+ const [measurements, setMeasurements] = useState(empty);
92
+ const [dragging, setDragging] = useState(null);
93
+ const metrics = useRef(measurements);
94
+ metrics.current = measurements;
95
+ const drag = useRef(null);
96
+ useImperativeHandle(forwardedRef, () => viewport.current, []);
97
+ useBrowserLayoutEffect(() => {
98
+ const node = viewport.current, contents = content.current;
99
+ if (!node || !contents || typeof ResizeObserver === "undefined")
100
+ return;
101
+ let frame = 0;
102
+ const measure = () => {
103
+ frame = 0;
104
+ const computed = getComputedStyle(node), rootSize = parseFloat(getComputedStyle(node.ownerDocument.documentElement).fontSize);
105
+ const length = (name) => { const value = computed.getPropertyValue(name).trim(); return parseFloat(value) * (value.endsWith("rem") ? rootSize : 1); };
106
+ const size = length("--flood-sys-scrollbar-size"), inset = length("--flood-sys-scrollbar-inset"), minimum = length("--flood-sys-scrollbar-min-thumb");
107
+ const enhanced = size > 0 && inset >= 0 && minimum > 0;
108
+ if (!enhanced) {
109
+ setMeasurements((old) => old.enhanced ? empty : old);
110
+ return;
111
+ }
112
+ const hasVertical = orientation !== "horizontal" && node.scrollHeight > node.clientHeight + 1;
113
+ const hasHorizontal = orientation !== "vertical" && node.scrollWidth > node.clientWidth + 1;
114
+ const rtl = computed.direction === "rtl";
115
+ const make = (axis) => {
116
+ const isVertical = axis === "vertical";
117
+ const client = isVertical ? node.clientHeight : node.clientWidth, total = isVertical ? node.scrollHeight : node.scrollWidth;
118
+ const cross = isVertical ? rtl ? inset : node.clientWidth - inset - size : node.clientHeight - inset - size;
119
+ const mainStart = inset + (!isVertical && hasVertical && rtl ? size + inset : 0);
120
+ const mainEnd = client - inset - ((isVertical ? hasHorizontal : hasVertical && !rtl) ? size + inset : 0);
121
+ const bounds = cornerBounds(node, axis, cross, cross + size, mainStart, mainEnd, inset);
122
+ const length = Math.max(0, bounds.end - bounds.start);
123
+ const thumb = Math.min(length, Math.max(minimum, total > 0 ? length * client / total : length));
124
+ const max = Math.max(0, total - client), offset = isVertical ? node.scrollTop : horizontalOffset(node, rtl);
125
+ return { visible: isVertical ? hasVertical : hasHorizontal, length, thumb, max, offset, position: max > 0 ? offset / max * (length - thumb) : 0, size,
126
+ left: node.offsetLeft + node.clientLeft + (isVertical ? cross : bounds.start),
127
+ top: node.offsetTop + node.clientTop + (isVertical ? bounds.start : cross) };
128
+ };
129
+ const next = { enhanced, vertical: make("vertical"), horizontal: make("horizontal") };
130
+ setMeasurements((old) => old.enhanced === next.enhanced && sameBar(old.vertical, next.vertical) && sameBar(old.horizontal, next.horizontal) ? old : next);
131
+ };
132
+ const schedule = () => { if (!frame)
133
+ frame = requestAnimationFrame(measure); };
134
+ const resize = new ResizeObserver(schedule);
135
+ resize.observe(node);
136
+ resize.observe(contents);
137
+ const mutation = new MutationObserver(schedule);
138
+ mutation.observe(contents, { subtree: true, childList: true, characterData: true, attributes: true });
139
+ // A writing-direction change can move the vertical rail without resizing the content.
140
+ const direction = new MutationObserver(schedule);
141
+ let ancestor = node;
142
+ while (ancestor) {
143
+ direction.observe(ancestor, { attributes: true, attributeFilter: ["dir", "class", "style"] });
144
+ ancestor = ancestor.parentElement;
145
+ }
146
+ node.addEventListener("scroll", schedule, { passive: true });
147
+ window.addEventListener("resize", schedule);
148
+ measure();
149
+ return () => { if (frame)
150
+ cancelAnimationFrame(frame); resize.disconnect(); mutation.disconnect(); direction.disconnect(); node.removeEventListener("scroll", schedule); window.removeEventListener("resize", schedule); };
151
+ }, [orientation, contentInset]);
152
+ useEffect(() => {
153
+ const wheel = (event) => {
154
+ const node = viewport.current;
155
+ if (!node)
156
+ return;
157
+ const computed = getComputedStyle(node);
158
+ const multiplier = event.deltaMode === 1 ? parseFloat(computed.lineHeight) || 16 : event.deltaMode === 2 ? node.clientHeight : 1;
159
+ const deltaY = orientation !== "horizontal" ? event.deltaY * multiplier : 0;
160
+ const deltaX = orientation !== "vertical" ? (orientation === "horizontal" && !event.deltaX ? event.deltaY : event.deltaX) * multiplier : 0;
161
+ const beforeY = node.scrollTop, beforeX = horizontalOffset(node, computed.direction === "rtl");
162
+ const nextY = Math.max(0, Math.min(node.scrollHeight - node.clientHeight, beforeY + deltaY));
163
+ const nextX = Math.max(0, Math.min(node.scrollWidth - node.clientWidth, beforeX + deltaX));
164
+ const consumed = nextY !== beforeY || nextX !== beforeX;
165
+ const contained = (deltaY !== 0 && ["contain", "none"].includes(computed.overscrollBehaviorY)) || (deltaX !== 0 && ["contain", "none"].includes(computed.overscrollBehaviorX));
166
+ if (consumed || contained)
167
+ event.preventDefault();
168
+ if (deltaY)
169
+ node.scrollTop = nextY;
170
+ if (deltaX)
171
+ scrollToOffset(node, "horizontal", nextX);
172
+ };
173
+ const tracks = [vertical.current, horizontal.current].filter((track) => Boolean(track));
174
+ tracks.forEach((track) => track.addEventListener("wheel", wheel, { passive: false }));
175
+ return () => tracks.forEach((track) => track.removeEventListener("wheel", wheel));
176
+ }, [measurements.vertical.visible, measurements.horizontal.visible, orientation]);
177
+ function pointerDown(event, axis) {
178
+ if (event.button !== 0 || !viewport.current)
179
+ return;
180
+ event.preventDefault();
181
+ event.stopPropagation();
182
+ const bar = metrics.current[axis];
183
+ drag.current = { axis, pointer: event.pointerId, start: axis === "vertical" ? event.clientY : event.clientX, offset: bar.offset };
184
+ event.currentTarget.setPointerCapture(event.pointerId);
185
+ setDragging(axis);
186
+ }
187
+ function pointerMove(event) {
188
+ const active = drag.current, node = viewport.current;
189
+ if (!active || active.pointer !== event.pointerId || !node)
190
+ return;
191
+ const bar = metrics.current[active.axis], distance = (active.axis === "vertical" ? event.clientY : event.clientX) - active.start;
192
+ if (bar.length > bar.thumb)
193
+ scrollToOffset(node, active.axis, active.offset + distance * bar.max / (bar.length - bar.thumb));
194
+ }
195
+ function pointerEnd() { drag.current = null; setDragging(null); }
196
+ function trackDown(event, axis) {
197
+ if (event.button !== 0 || !viewport.current)
198
+ return;
199
+ event.preventDefault();
200
+ event.stopPropagation();
201
+ const bar = metrics.current[axis], rect = event.currentTarget.getBoundingClientRect();
202
+ const click = axis === "vertical" ? event.clientY - rect.top : event.clientX - rect.left;
203
+ if (bar.length > bar.thumb)
204
+ scrollToOffset(viewport.current, axis, (click - bar.thumb / 2) * bar.max / (bar.length - bar.thumb));
205
+ }
206
+ const renderBar = (axis) => {
207
+ const bar = measurements[axis];
208
+ if (!measurements.enhanced || !bar.visible)
209
+ return null;
210
+ const isVertical = axis === "vertical";
211
+ return _jsx("div", { ref: isVertical ? vertical : horizontal, className: `flood-scroll-area__track flood-scroll-area__track--${axis}`, "aria-hidden": "true", "data-dragging": dragging === axis || undefined, style: { left: bar.left, top: bar.top, width: isVertical ? bar.size : bar.length, height: isVertical ? bar.length : bar.size }, onPointerDown: (event) => trackDown(event, axis), children: _jsx("div", { className: "flood-scroll-area__thumb", style: { width: isVertical ? "100%" : bar.thumb, height: isVertical ? bar.thumb : "100%", transform: isVertical ? `translateY(${bar.position}px)` : `translateX(${bar.position}px)` }, onPointerDown: (event) => pointerDown(event, axis), onPointerMove: pointerMove, onPointerUp: pointerEnd, onPointerCancel: pointerEnd, onLostPointerCapture: pointerEnd }) });
212
+ };
213
+ const rootStyle = { width: style?.width, minWidth: style?.minWidth, maxWidth: style?.maxWidth, inlineSize: style?.inlineSize, minInlineSize: style?.minInlineSize, maxInlineSize: style?.maxInlineSize };
214
+ const contentStyle = contentInset === "scrollbar" && measurements.enhanced ? {
215
+ "--flood-scroll-content-inline": measurements.vertical.visible ? "calc(var(--flood-sys-scrollbar-size) + 2 * var(--flood-sys-scrollbar-inset))" : "0px",
216
+ "--flood-scroll-content-block": measurements.horizontal.visible ? "calc(var(--flood-sys-scrollbar-size) + 2 * var(--flood-sys-scrollbar-inset))" : "0px",
217
+ } : undefined;
218
+ const named = props["aria-label"] || props["aria-labelledby"];
219
+ return _jsxs("div", { className: "flood-scroll-area", hidden: props.hidden, dir: props.dir, lang: props.lang, style: rootStyle, children: [_jsx("div", { ...props, id: props.id ?? `flood-scroll-${id}`, ref: viewport, tabIndex: tabIndex, role: role ?? (named ? "region" : undefined), className: ["flood-scroll-area__viewport", className].filter(Boolean).join(" "), style: style, onFocus: event => {
220
+ onFocus?.(event);
221
+ if (event.defaultPrevented || event.target === event.currentTarget)
222
+ return;
223
+ const target = event.target, node = event.currentTarget;
224
+ if (node.ownerDocument.activeElement !== target)
225
+ return;
226
+ const layer = target.closest("[popover], dialog[open]");
227
+ if (layer && node.contains(layer))
228
+ return;
229
+ const box = target.getBoundingClientRect(), bounds = node.getBoundingClientRect();
230
+ const outsideX = orientation !== "vertical" && (box.left < bounds.left || box.right > bounds.right);
231
+ const outsideY = orientation !== "horizontal" && (box.top < bounds.top || box.bottom > bounds.bottom);
232
+ if (outsideX || outsideY)
233
+ target.scrollIntoView({ block: "nearest", inline: "nearest" });
234
+ }, "data-orientation": orientation, "data-scrollbars": measurements.enhanced ? "overlay" : undefined, children: _jsx("div", { className: "flood-scroll-area__content", ref: content, style: contentStyle, children: children }) }), renderBar("vertical"), renderBar("horizontal")] });
235
+ });
@@ -0,0 +1,3 @@
1
+ import type { SelectProps } from "./index.js";
2
+ /** Internal implementation. The public entry retains the native SelectProps/ref contract. */
3
+ export declare const SingleSelect: import("react").ForwardRefExoticComponent<SelectProps & import("react").RefAttributes<HTMLSelectElement>>;