@flood-md/react 0.2.11 → 0.2.13

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.
package/dist/menu.js CHANGED
@@ -40,7 +40,7 @@ export function focusMenuEdge(panel, edge = "first") {
40
40
  ((edge === "last" ? all.at(-1) : all[0]) ?? panel)?.focus({ preventScroll: true });
41
41
  }
42
42
  /** Internal native top-layer engine shared by Menu and ContextMenu. */
43
- export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ label, items, open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, onAction, edge, nested = false, position, side = "bottom", align = "start", controlSize = "sm", id: explicitId, className, onKeyDown, ...props }, forwardedRef) {
43
+ export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ label, items, open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, onAction, getFallbackFocus, edge, nested = false, position, side = "bottom", align = "start", controlSize = "sm", id: explicitId, className, onKeyDown, ...props }, forwardedRef) {
44
44
  const generated = useId(), id = explicitId ?? `flood-menu-${generated}`;
45
45
  const panel = useRef(null), anchors = useRef(new Map()), children = useRef(new Map());
46
46
  const anchorRefs = useRef(new Map());
@@ -50,8 +50,8 @@ export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ l
50
50
  const hoverIntent = useRef(undefined);
51
51
  const latestItems = useRef(new Map());
52
52
  const search = useRef({ text: "", time: 0 }), pointerTarget = useRef(null);
53
- const current = useRef({ open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge });
54
- current.current = { open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge };
53
+ const current = useRef({ open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge, getFallbackFocus });
54
+ current.current = { open, requestOpen, closeTree, anchor, rootAnchor, rootPanel, position, edge, getFallbackFocus };
55
55
  useImperativeHandle(forwardedRef, () => panel.current, []);
56
56
  const clearHover = () => { clearTimeout(hover.current); hover.current = undefined; hoverIntent.current = undefined; };
57
57
  useEffect(() => () => clearHover(), []);
@@ -166,7 +166,7 @@ export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ l
166
166
  unregister();
167
167
  document.removeEventListener("pointerdown", trackPointer, true);
168
168
  element.removeEventListener("toggle", toggle);
169
- returnFocus(element, current.current.anchor());
169
+ returnFocus(element, current.current.anchor(), current.current.getFallbackFocus);
170
170
  if (element.matches(":popover-open"))
171
171
  element.hidePopover();
172
172
  };
@@ -328,7 +328,7 @@ export const MenuLayer = /* @__PURE__ */ forwardRef(function MenuLayerRender({ l
328
328
  }
329
329
  }, children: showContent && _jsx(ScrollArea, { orientation: "vertical", role: "group", tabIndex: -1, className: "flood-menu__viewport", children: rows }) });
330
330
  });
331
- export const ActionMenu = /* @__PURE__ */ forwardRef(function ActionMenu({ label, trigger, items, onAction, open: controlled, defaultOpen, onOpenChange, disabled, side = "bottom", align = "start", controlSize = "sm", ...props }, forwardedRef) {
331
+ export const ActionMenu = /* @__PURE__ */ forwardRef(function ActionMenu({ label, trigger, items, onAction, open: controlled, defaultOpen, onOpenChange, disabled, getFallbackFocus, side = "bottom", align = "start", controlSize = "sm", ...props }, forwardedRef) {
332
332
  if (!label.trim())
333
333
  throw new Error("Flood Menu requires a nonempty label");
334
334
  validateMenuTree(items);
@@ -365,5 +365,5 @@ export const ActionMenu = /* @__PURE__ */ forwardRef(function ActionMenu({ label
365
365
  } };
366
366
  const generated = useId(), id = props.id ?? `flood-menu-${generated}`;
367
367
  triggerProps["aria-controls"] = id;
368
- return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx(MenuLayer, { ...props, id: id, ref: panel, label: label, items: items, open: open, requestOpen: request, closeTree: () => request(false), anchor: () => anchor.current, rootAnchor: () => anchor.current, onAction: onAction, edge: () => edge.current, side: side, align: align, controlSize: controlSize })] });
368
+ return _jsxs(_Fragment, { children: [trigger(triggerProps), _jsx(MenuLayer, { ...props, id: id, ref: panel, label: label, items: items, open: open, requestOpen: request, closeTree: () => request(false), anchor: () => anchor.current, rootAnchor: () => anchor.current, onAction: onAction, getFallbackFocus: getFallbackFocus, edge: () => edge.current, side: side, align: align, controlSize: controlSize })] });
369
369
  });
@@ -18,7 +18,7 @@ export declare function placeOverlay(anchor: Rect, size: {
18
18
  top: number;
19
19
  width: number;
20
20
  height: number;
21
- }, side: Side, align: Align, gap: number, rtl?: boolean): {
21
+ }, side: Side, align: Align, gap: number, rtl?: boolean, overlap?: boolean): {
22
22
  left: number;
23
23
  top: number;
24
24
  side: Side;
@@ -29,13 +29,14 @@ type Layer = {
29
29
  anchor: () => HTMLElement | null;
30
30
  close: () => void;
31
31
  outside?: () => void;
32
+ closeOnFocusOutside?: boolean;
32
33
  modal?: boolean;
33
34
  tooltip?: boolean;
34
35
  };
35
36
  export declare function registerLayer(document: Document, layer: Layer): () => void;
36
37
  export declare function lockScroll(document: Document): () => void;
37
- export declare function positionLayer(anchor: HTMLElement, panel: HTMLElement, side: Side, align: Align): void;
38
- export declare function returnFocus(panel: HTMLElement, anchor: HTMLElement | null): void;
38
+ export declare function positionLayer(anchor: HTMLElement, panel: HTMLElement, side: Side, align: Align, overlap?: boolean): void;
39
+ export declare function returnFocus(panel: HTMLElement, anchor: HTMLElement | null, fallback?: () => HTMLElement | null): void;
39
40
  /** Retain pixels for a CSS exit after the native layer has released focus. */
40
- export declare function useLayerExitContent(panel: RefObject<HTMLElement | null>, open: boolean, enabled?: boolean): boolean;
41
+ export declare function useLayerExitContent(panel: RefObject<HTMLElement | null>, open: boolean, enabled?: boolean, transitionProperty?: string): boolean;
41
42
  export {};
@@ -1,16 +1,18 @@
1
1
  import { useEffect, useLayoutEffect, useState } from "react";
2
2
  /** Pure collision handling in layout-viewport coordinates. */
3
- export function placeOverlay(anchor, size, viewport, side, align, gap, rtl = false) {
3
+ export function placeOverlay(anchor, size, viewport, side, align, gap, rtl = false, overlap = false) {
4
4
  const edge = { left: viewport.left + gap, right: viewport.left + viewport.width - gap, top: viewport.top + gap, bottom: viewport.top + viewport.height - gap };
5
5
  const space = { top: anchor.top - edge.top, bottom: edge.bottom - anchor.bottom, left: anchor.left - edge.left, right: edge.right - anchor.right };
6
6
  const opposite = { top: "bottom", bottom: "top", left: "right", right: "left" };
7
7
  const needed = (side === "top" || side === "bottom" ? size.height : size.width) + gap;
8
- if (space[side] < needed && space[opposite[side]] > space[side])
8
+ if (!overlap && space[side] < needed && space[opposite[side]] > space[side])
9
9
  side = opposite[side];
10
10
  const horizontal = side === "top" || side === "bottom";
11
11
  const resolved = horizontal && rtl ? (align === "start" ? "end" : align === "end" ? "start" : align) : align;
12
12
  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;
13
13
  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);
14
+ if (overlap)
15
+ top = anchor.top;
14
16
  left = Math.max(edge.left, Math.min(left, edge.right - size.width));
15
17
  top = Math.max(edge.top, Math.min(top, edge.bottom - size.height));
16
18
  return { left, top, side };
@@ -58,7 +60,7 @@ export function registerLayer(document, layer) {
58
60
  (layer.outside ?? layer.close)();
59
61
  };
60
62
  const focus = (event) => {
61
- if (top() !== layer || layer.modal)
63
+ if (top() !== layer || layer.modal || layer.closeOnFocusOutside === false)
62
64
  return;
63
65
  const target = event.target;
64
66
  if (!layer.panel.contains(target) && !layer.anchor()?.contains(target))
@@ -109,7 +111,7 @@ export function lockScroll(document) {
109
111
  locks.delete(document);
110
112
  };
111
113
  }
112
- export function positionLayer(anchor, panel, side, align) {
114
+ export function positionLayer(anchor, panel, side, align, overlap = false) {
113
115
  const view = panel.ownerDocument.defaultView;
114
116
  const visual = view.visualViewport;
115
117
  const viewport = { left: visual?.offsetLeft ?? 0, top: visual?.offsetTop ?? 0, width: visual?.width ?? view.innerWidth, height: visual?.height ?? view.innerHeight };
@@ -121,7 +123,7 @@ export function positionLayer(anchor, panel, side, align) {
121
123
  panel.style.setProperty("--flood-overlay-anchor-width", anchorWidth);
122
124
  if (panel.style.getPropertyValue("--flood-overlay-anchor-height") !== anchorHeight)
123
125
  panel.style.setProperty("--flood-overlay-anchor-height", anchorHeight);
124
- const position = placeOverlay(bounds, panel.getBoundingClientRect(), viewport, side, align, gap, getComputedStyle(anchor).direction === "rtl");
126
+ const position = placeOverlay(bounds, panel.getBoundingClientRect(), viewport, side, align, gap, getComputedStyle(anchor).direction === "rtl", overlap);
125
127
  const left = `${position.left}px`, top = `${position.top}px`;
126
128
  if (panel.style.left !== left)
127
129
  panel.style.left = left;
@@ -130,13 +132,34 @@ export function positionLayer(anchor, panel, side, align) {
130
132
  panel.dataset.side = position.side;
131
133
  panel.style.visibility = "visible";
132
134
  }
133
- export function returnFocus(panel, anchor) {
134
- const active = panel.ownerDocument.activeElement;
135
- if (anchor?.isConnected && !anchor.matches(":disabled") && (panel.contains(active) || active === panel.ownerDocument.body))
136
- anchor.focus({ preventScroll: true });
135
+ export function returnFocus(panel, anchor, fallback) {
136
+ const document = panel.ownerDocument;
137
+ const needsFocus = () => {
138
+ const active = document.activeElement;
139
+ return !active || active === document.body || panel.contains(active);
140
+ };
141
+ const focus = (target) => {
142
+ if (!target?.isConnected || target.ownerDocument !== document || panel.contains(target) ||
143
+ target.matches(':disabled,[aria-disabled="true"]') || target.closest("[hidden],[inert]") ||
144
+ !target.getClientRects().length || document.defaultView?.getComputedStyle(target).visibility !== "visible")
145
+ return;
146
+ target.focus({ preventScroll: true });
147
+ };
148
+ if (needsFocus())
149
+ focus(anchor);
150
+ else if (document.activeElement !== anchor)
151
+ return;
152
+ if (!fallback)
153
+ return;
154
+ // A surviving opener can still be removed in the same React commit.
155
+ queueMicrotask(() => {
156
+ if (panel.matches("dialog[open]") || panel.matches(":popover-open") || !needsFocus())
157
+ return;
158
+ focus(fallback());
159
+ });
137
160
  }
138
161
  /** Retain pixels for a CSS exit after the native layer has released focus. */
139
- export function useLayerExitContent(panel, open, enabled = true) {
162
+ export function useLayerExitContent(panel, open, enabled = true, transitionProperty = "opacity") {
140
163
  const [retained, setRetained] = useState(false);
141
164
  useLayerLayoutEffect(() => {
142
165
  const node = panel.current;
@@ -149,7 +172,7 @@ export function useLayerExitContent(panel, open, enabled = true) {
149
172
  return;
150
173
  }
151
174
  node.inert = true;
152
- const transitions = node.getAnimations().filter(animation => "transitionProperty" in animation && animation.transitionProperty === "opacity");
175
+ const transitions = node.getAnimations().filter(animation => "transitionProperty" in animation && animation.transitionProperty === transitionProperty);
153
176
  let active = true;
154
177
  if (!transitions.length)
155
178
  setRetained(false);
@@ -159,7 +182,7 @@ export function useLayerExitContent(panel, open, enabled = true) {
159
182
  setRetained(false);
160
183
  });
161
184
  return () => { active = false; node.inert = false; };
162
- }, [panel, open, enabled]);
185
+ }, [panel, open, enabled, transitionProperty]);
163
186
  return open || enabled && retained;
164
187
  }
165
188
  const useLayerLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -7,6 +7,7 @@ interface ModalOverlayProps extends DialogProps {
7
7
  showCloseButton?: boolean;
8
8
  role?: "dialog" | "alertdialog";
9
9
  scrollBody?: boolean;
10
+ bodyContentInset?: "none" | "scrollbar";
10
11
  footer?: import("react").ReactNode;
11
12
  }
12
13
  export declare const ModalOverlay: import("react").ForwardRefExoticComponent<ModalOverlayProps & import("react").RefAttributes<HTMLDialogElement>>;
package/dist/overlays.js CHANGED
@@ -126,12 +126,14 @@ export const AnchoredOverlay = /* @__PURE__ */ forwardRef(function AnchoredOverl
126
126
  leave();
127
127
  } }, children: kind === "tooltip" ? tip.content : _jsxs(_Fragment, { children: [pop.chrome === "minimal" ? _jsx(VisuallyHidden, { children: _jsx("h2", { id: `${id}-title`, children: pop.title }) }) : _jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: pop.title }), !titleOnly && _jsx(OverlayClose, { label: pop.closeLabel ?? "Close", onClick: close })] }), showContent && _jsx("div", { className: "flood-overlay-body", children: pop.children })] }) })] });
128
128
  });
129
- export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ showCloseButton = true, scrollBody = false, footer, 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) {
129
+ export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ showCloseButton = true, scrollBody = false, bodyContentInset, footer, title, description, trigger, children, open: controlled, defaultOpen = false, onOpenChange, disabled = false, closeLabel = "Close", closeOnOutsideClick = true, initialFocusRef, getFallbackFocus, id: explicitId, className, onKeyDown, "aria-describedby": describedBy, ...props }, forwardedRef) {
130
130
  const [local, setLocal] = useState(defaultOpen), open = controlled ?? local;
131
131
  const dialog = useRef(null), anchor = useRef(null), generated = useId(), id = explicitId ?? `flood-dialog-${generated}`;
132
132
  const request = (next) => { if (controlled === undefined)
133
133
  setLocal(next); onOpenChange?.(next); };
134
134
  const current = useCurrent({ open, request, closeOnOutsideClick, initialFocusRef });
135
+ const fallbackFocus = useRef(getFallbackFocus);
136
+ fallbackFocus.current = getFallbackFocus;
135
137
  const setAnchor = useCallback((node) => { anchor.current = node; }, []);
136
138
  useImperativeHandle(forwardedRef, () => dialog.current, []);
137
139
  useBrowserLayoutEffect(() => {
@@ -168,7 +170,7 @@ export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ s
168
170
  element.addEventListener("close", closed);
169
171
  focusContent(element, current.current.initialFocusRef?.current);
170
172
  dispose = () => { unregister(); unlock(); element.removeEventListener("cancel", cancel); element.removeEventListener("close", closed); if (element.open)
171
- element.close(); returnFocus(element, anchor.current ?? previous); };
173
+ element.close(); returnFocus(element, anchor.current ?? previous, fallbackFocus.current ? () => fallbackFocus.current?.() ?? null : undefined); };
172
174
  };
173
175
  start();
174
176
  return () => { alive = false; view.cancelAnimationFrame(frame); dispose(); };
@@ -194,5 +196,5 @@ export const ModalOverlay = /* @__PURE__ */ forwardRef(function ModalOverlay({ s
194
196
  event.preventDefault();
195
197
  first.focus();
196
198
  }
197
- }, children: [_jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: title }), showCloseButton && _jsx(OverlayClose, { label: closeLabel, onClick: () => request(false) })] }), description && _jsx("p", { id: `${id}-description`, className: "flood-overlay-description", children: description }), open && (scrollBody ? _jsx("div", { className: "flood-drawer__body", children: _jsx(ScrollArea, { className: "flood-overlay-body", style: { height: "100%" }, children: children }) }) : _jsx("div", { className: "flood-overlay-body", children: children })), open && footer && _jsx("div", { className: "flood-drawer__footer", children: footer })] })] });
199
+ }, children: [_jsxs("div", { className: "flood-overlay-header", children: [_jsx("h2", { id: `${id}-title`, className: "flood-overlay-title", children: title }), showCloseButton && _jsx(OverlayClose, { label: closeLabel, onClick: () => request(false) })] }), description && _jsx("p", { id: `${id}-description`, className: "flood-overlay-description", children: description }), open && (scrollBody ? _jsx("div", { className: "flood-drawer__body", children: _jsx(ScrollArea, { className: "flood-overlay-body", contentInset: bodyContentInset, style: { height: "100%" }, children: children }) }) : _jsx("div", { className: "flood-overlay-body", children: children })), open && footer && _jsx("div", { className: "flood-drawer__footer", children: footer })] })] });
198
200
  });
@@ -0,0 +1,2 @@
1
+ import { type RangeChartProps } from "./index.js";
2
+ export declare const RangePlot: import("react").ForwardRefExoticComponent<RangeChartProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,97 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId, useState } from "react";
3
+ import { Surface, Text, VisuallyHidden } from "./index.js";
4
+ const number = new Intl.NumberFormat("en", { maximumSignificantDigits: 21 });
5
+ export const RangePlot = /* @__PURE__ */ forwardRef(function RangePlot({ label, items, domain, formatValue = value => number.format(value), lowLabel = "Low", highLabel = "High", midpointLabel = "Midpoint", emptyLabel = "No observations", missingLabel = "No data", navigationLabel = "Use the arrow keys to explore categories, or Home and End to jump.", className, ...props }, ref) {
6
+ const instructions = useId();
7
+ const [cursor, setCursor] = useState(0);
8
+ const [hovered, setHovered] = useState();
9
+ const [tapped, setTapped] = useState();
10
+ const [focused, setFocused] = useState(false);
11
+ let min = Infinity;
12
+ let max = -Infinity;
13
+ const ids = new Set();
14
+ const intervals = items.map(item => {
15
+ const { id, label: name, low, high, midpoint } = item;
16
+ if (!id.trim() || ids.has(id) || !name.trim()) {
17
+ throw new Error("Flood RangeChart needs unique item IDs and nonempty labels");
18
+ }
19
+ ids.add(id);
20
+ if ((low !== null && !Number.isFinite(low)) || (high !== null && !Number.isFinite(high)) ||
21
+ (midpoint !== undefined && !Number.isFinite(midpoint))) {
22
+ throw new RangeError("Flood RangeChart bounds and midpoints must be finite");
23
+ }
24
+ if (low === null || high === null)
25
+ return undefined;
26
+ if (low > high || (midpoint !== undefined && (midpoint < low || midpoint > high))) {
27
+ throw new RangeError("Flood RangeChart midpoint must lie within its ordered bounds");
28
+ }
29
+ min = Math.min(min, low);
30
+ max = Math.max(max, high);
31
+ return { low, high, midpoint };
32
+ });
33
+ const hasData = min <= max;
34
+ if (domain && (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) ||
35
+ domain[0] >= domain[1] || (hasData && (domain[0] > min || domain[1] < max)))) {
36
+ throw new RangeError("Flood RangeChart domain must contain all complete intervals and have finite increasing bounds");
37
+ }
38
+ const [floor, ceiling] = domain ?? [min, max];
39
+ const span = ceiling - floor;
40
+ const scale = (value) => span === 0 ? .5 : Number.isFinite(span)
41
+ ? (value - floor) / span
42
+ : (value / 2 - floor / 2) / (ceiling / 2 - floor / 2);
43
+ const last = items.length - 1;
44
+ const active = Math.min(hovered ?? tapped ?? cursor, last);
45
+ const inspecting = hovered !== undefined || tapped !== undefined || focused;
46
+ const current = intervals[active];
47
+ const readings = current ? [
48
+ { name: highLabel, value: formatValue(current.high) },
49
+ ...(current.midpoint === undefined ? [] : [{ name: midpointLabel, value: formatValue(current.midpoint) }]),
50
+ { name: lowLabel, value: formatValue(current.low) },
51
+ ] : [];
52
+ const accessibleValue = [items[active]?.label, ...(current
53
+ ? readings.map(reading => `${reading.name}: ${reading.value}`)
54
+ : [missingLabel])].filter(Boolean).join(", ");
55
+ const navigate = (event) => {
56
+ let next;
57
+ switch (event.key) {
58
+ case "ArrowRight":
59
+ case "ArrowUp":
60
+ next = active + 1;
61
+ break;
62
+ case "ArrowLeft":
63
+ case "ArrowDown":
64
+ next = active - 1;
65
+ break;
66
+ case "Home":
67
+ next = 0;
68
+ break;
69
+ case "End":
70
+ next = last;
71
+ break;
72
+ case "Escape":
73
+ setHovered(undefined);
74
+ setTapped(undefined);
75
+ setFocused(false);
76
+ return;
77
+ default: return;
78
+ }
79
+ event.preventDefault();
80
+ setHovered(undefined);
81
+ setTapped(undefined);
82
+ setFocused(true);
83
+ setCursor(Math.max(0, Math.min(last, next)));
84
+ };
85
+ return _jsx("div", { ...props, ref: ref, className: ["flood-range-chart", className].filter(Boolean).join(" "), role: "group", "aria-label": label, children: hasData ? _jsxs(_Fragment, { children: [last > 0 && _jsx(VisuallyHidden, { id: instructions, children: navigationLabel }), _jsxs("div", { className: "flood-range-chart__plot", dir: "ltr", style: { "--flood-range-columns": items.length }, role: last > 0 ? "slider" : "img", tabIndex: last > 0 ? 0 : undefined, "aria-label": last > 0 ? label : accessibleValue, "aria-describedby": last > 0 ? instructions : undefined, "aria-valuemin": last > 0 ? 1 : undefined, "aria-valuemax": last > 0 ? items.length : undefined, "aria-valuenow": last > 0 ? active + 1 : undefined, "aria-valuetext": last > 0 ? accessibleValue : undefined, onKeyDown: navigate, onFocus: event => setFocused(event.currentTarget.matches(":focus-visible")), onBlur: () => setFocused(false), onPointerLeave: () => setHovered(undefined), children: [_jsx("div", { className: "flood-range-chart__columns", "aria-hidden": "true", children: items.map((item, index) => {
86
+ const interval = intervals[index];
87
+ return _jsxs("div", { className: "flood-range-chart__column", "data-active": inspecting && index === active || undefined, onPointerEnter: event => { if (event.pointerType !== "touch")
88
+ setHovered(index); }, onPointerUp: event => { if (event.pointerType === "touch")
89
+ setTapped(previous => previous === index ? undefined : index); }, children: [_jsx("div", { className: "flood-range-chart__track", children: interval && _jsxs(_Fragment, { children: [_jsx("span", { className: "flood-range-chart__band", style: {
90
+ insetBlockStart: `${(1 - scale(interval.high)) * 100}%`,
91
+ insetBlockEnd: interval.low === interval.high ? undefined : `${scale(interval.low) * 100}%`,
92
+ }, "data-flat": interval.low === interval.high || undefined }), interval.midpoint !== undefined && _jsx("span", { className: "flood-range-chart__midpoint", style: { insetBlockStart: `${(1 - scale(interval.midpoint)) * 100}%` } })] }) }), _jsx(Text, { className: "flood-range-chart__category", size: "helper", tone: "subtle", dir: "auto", title: item.label, children: item.label })] }, item.id);
93
+ }) }), inspecting && _jsxs(Surface, { className: "flood-chart__tooltip flood-range-chart__tooltip", padding: "2", elevation: "raised", "aria-hidden": "true", style: {
94
+ "--flood-range-position": `${(active + .5) / items.length * 100}%`,
95
+ "--flood-range-tip-side": active >= items.length / 2 ? -1 : 0,
96
+ }, children: [_jsx(Text, { size: "helper", children: items[active].label }), current ? readings.map((reading, index) => _jsxs("div", { className: "flood-range-chart__reading", children: [_jsx(Text, { size: "helper", tone: "subtle", children: reading.name }), _jsx(Text, { size: "helper", children: reading.value })] }, index)) : _jsx(Text, { size: "helper", tone: "subtle", children: missingLabel })] })] })] }) : _jsx(Text, { className: "flood-range-chart__empty", size: "compact", tone: "subtle", children: emptyLabel }) });
97
+ });
@@ -0,0 +1,2 @@
1
+ import { type ScatterChartProps } from "./index.js";
2
+ export declare const ScatterPlot: import("react").ForwardRefExoticComponent<ScatterChartProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,170 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { Surface, Text, VisuallyHidden } from "./index.js";
4
+ import { placeOverlay } from "./overlay-runtime.js";
5
+ const numberFormat = new Intl.NumberFormat("en", { maximumSignificantDigits: 21 });
6
+ const formatNumber = (value) => numberFormat.format(value);
7
+ const useChartLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
8
+ function scaleFor(points, axis, domain) {
9
+ let low = Infinity;
10
+ let high = -Infinity;
11
+ for (const point of points) {
12
+ low = Math.min(low, point[axis]);
13
+ high = Math.max(high, point[axis]);
14
+ }
15
+ if (domain) {
16
+ const [min, max] = domain;
17
+ if (!Number.isFinite(min) || !Number.isFinite(max) || min >= max || low < min || high > max) {
18
+ throw new RangeError(`Flood ScatterChart ${axis} domain must have finite increasing bounds containing all observations`);
19
+ }
20
+ return domain;
21
+ }
22
+ return points.length ? [low, high] : [0, 1];
23
+ }
24
+ function normalize(value, [min, max]) {
25
+ if (min === max)
26
+ return .5;
27
+ const span = max - min;
28
+ return Number.isFinite(span) ? (value - min) / span : (value / 2 - min / 2) / (max / 2 - min / 2);
29
+ }
30
+ function ticks([min, max]) {
31
+ const midpoint = min / 2 + max / 2;
32
+ return min === max ? [min] : midpoint === min || midpoint === max ? [min, max] : [min, midpoint, max];
33
+ }
34
+ export const ScatterPlot = /* @__PURE__ */ forwardRef(function ScatterPlot({ label, xLabel, yLabel, points, xDomain, yDomain, formatX = formatNumber, formatY = formatNumber, emptyLabel = "No data", navigationLabel = "Use arrow keys to inspect points, or Home and End to jump.", className, ...props }, ref) {
35
+ const instructions = useId();
36
+ const plot = useRef(null);
37
+ const area = useRef(null);
38
+ const tooltip = useRef(null);
39
+ const [cursor, setCursor] = useState();
40
+ const [hover, setHover] = useState();
41
+ const [touch, setTouch] = useState();
42
+ const [focused, setFocused] = useState(false);
43
+ const data = useMemo(() => {
44
+ const ids = new Set();
45
+ for (const point of points) {
46
+ if (!point.id.trim() || ids.has(point.id) || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
47
+ throw new Error("Flood ScatterChart needs unique nonempty point IDs and finite coordinates");
48
+ }
49
+ ids.add(point.id);
50
+ }
51
+ const xScale = scaleFor(points, "x", xDomain);
52
+ const yScale = scaleFor(points, "y", yDomain);
53
+ return {
54
+ xScale,
55
+ yScale,
56
+ positions: points.map(point => ({
57
+ point,
58
+ x: normalize(point.x, xScale),
59
+ y: 1 - normalize(point.y, yScale),
60
+ })),
61
+ };
62
+ }, [points, xDomain, yDomain]);
63
+ const activeId = hover ?? touch ?? cursor;
64
+ const index = activeId === undefined ? 0 : Math.max(0, points.findIndex(point => point.id === activeId));
65
+ const active = data.positions[index];
66
+ const inspecting = Boolean(active && (hover !== undefined || touch !== undefined || focused));
67
+ const xText = active ? formatX(active.point.x) : "";
68
+ const yText = active ? formatY(active.point.y) : "";
69
+ const valueText = [active?.point.label, `${xLabel}: ${xText}`, `${yLabel}: ${yText}`].filter(Boolean).join(", ");
70
+ const canNavigate = points.length > 1;
71
+ useChartLayoutEffect(() => {
72
+ const panel = tooltip.current;
73
+ const frame = plot.current;
74
+ const content = area.current;
75
+ if (!panel || !frame || !content || !active || !inspecting)
76
+ return;
77
+ const position = () => {
78
+ const bounds = frame.getBoundingClientRect();
79
+ if (!bounds.width || !frame.offsetWidth)
80
+ return;
81
+ const region = content.getBoundingClientRect();
82
+ const scale = bounds.width / frame.offsetWidth;
83
+ const pointX = region.left + active.x * region.width;
84
+ const pointY = region.top + active.y * region.height;
85
+ // The panel's column gap is the anchor clearance. The inner grid owns text spacing.
86
+ const gap = parseFloat(getComputedStyle(panel).columnGap) * scale;
87
+ const anchor = { left: pointX, right: pointX, top: pointY, bottom: pointY, width: 0, height: 0 };
88
+ const size = panel.getBoundingClientRect();
89
+ const edge = gap / 4;
90
+ const insetAdjustment = gap - edge;
91
+ const placementBounds = {
92
+ left: bounds.left - insetAdjustment,
93
+ top: bounds.top - insetAdjustment,
94
+ width: bounds.width + 2 * insetAdjustment,
95
+ height: bounds.height + 2 * insetAdjustment,
96
+ };
97
+ const hasSideRoom = Math.max(pointX - bounds.left, bounds.right - pointX) >= size.width + gap + edge;
98
+ const side = hasSideRoom ? (active.x < .5 ? "right" : "left") : "top";
99
+ const { left, top } = placeOverlay(anchor, size, placementBounds, side, "center", gap);
100
+ panel.style.left = `${(left - bounds.left) / scale}px`;
101
+ panel.style.top = `${(top - bounds.top) / scale}px`;
102
+ panel.dataset.positioned = "";
103
+ };
104
+ position();
105
+ if (typeof ResizeObserver === "undefined")
106
+ return;
107
+ const observer = new ResizeObserver(position);
108
+ observer.observe(frame);
109
+ observer.observe(panel);
110
+ return () => observer.disconnect();
111
+ }, [active, inspecting, xText, yText]);
112
+ const nearest = (event) => {
113
+ const bounds = area.current?.getBoundingClientRect();
114
+ if (!bounds)
115
+ return undefined;
116
+ let distance = Infinity;
117
+ let id;
118
+ for (const { point, x, y } of data.positions) {
119
+ const next = (bounds.left + x * bounds.width - event.clientX) ** 2 + (bounds.top + y * bounds.height - event.clientY) ** 2;
120
+ if (next < distance) {
121
+ distance = next;
122
+ id = point.id;
123
+ }
124
+ }
125
+ return id;
126
+ };
127
+ const trackPointer = (event) => {
128
+ if (event.pointerType !== "touch")
129
+ setHover(nearest(event));
130
+ };
131
+ const navigate = (event) => {
132
+ let next;
133
+ switch (event.key) {
134
+ case "ArrowRight":
135
+ case "ArrowUp":
136
+ next = index + 1;
137
+ break;
138
+ case "ArrowLeft":
139
+ case "ArrowDown":
140
+ next = index - 1;
141
+ break;
142
+ case "Home":
143
+ next = 0;
144
+ break;
145
+ case "End":
146
+ next = points.length - 1;
147
+ break;
148
+ case "Escape":
149
+ if (inspecting)
150
+ event.preventDefault();
151
+ setHover(undefined);
152
+ setTouch(undefined);
153
+ setFocused(false);
154
+ return;
155
+ default: return;
156
+ }
157
+ event.preventDefault();
158
+ setHover(undefined);
159
+ setTouch(undefined);
160
+ setFocused(true);
161
+ setCursor(points[Math.max(0, Math.min(next, points.length - 1))].id);
162
+ };
163
+ return _jsx("div", { ...props, ref: ref, className: ["flood-scatter-chart", className].filter(Boolean).join(" "), role: "group", "aria-label": label, children: !active ? _jsx(Text, { className: "flood-scatter-chart__empty", size: "compact", tone: "subtle", children: emptyLabel }) : _jsxs(_Fragment, { children: [canNavigate && _jsx(VisuallyHidden, { id: instructions, children: navigationLabel }), _jsx(Text, { className: "flood-scatter-chart__vertical-label", size: "helper", tone: "subtle", "aria-hidden": "true", dir: "auto", children: yLabel }), _jsxs("div", { className: "flood-scatter-chart__layout", dir: "ltr", children: [_jsx("div", { className: "flood-scatter-chart__ticks flood-scatter-chart__ticks--y", "aria-hidden": "true", children: ticks(data.yScale).slice().reverse().map(tick => _jsx(Text, { size: "helper", tone: "subtle", children: formatY(tick) }, tick)) }), _jsxs("div", { ref: plot, className: "flood-scatter-chart__plot", role: canNavigate ? "slider" : "img", tabIndex: 0, "aria-label": canNavigate ? label : `${label}. ${valueText}`, "aria-describedby": canNavigate ? instructions : undefined, "aria-valuemin": canNavigate ? 1 : undefined, "aria-valuemax": canNavigate ? points.length : undefined, "aria-valuenow": canNavigate ? index + 1 : undefined, "aria-valuetext": canNavigate ? valueText : undefined, onFocus: event => setFocused(event.currentTarget.matches(":focus-visible")), onBlur: () => setFocused(false), onKeyDown: navigate, onPointerEnter: trackPointer, onPointerMove: trackPointer, onPointerLeave: () => setHover(undefined), onPointerUp: event => {
164
+ if (event.pointerType !== "touch")
165
+ return;
166
+ const next = nearest(event);
167
+ setHover(undefined);
168
+ setTouch(current => current === next ? undefined : next);
169
+ }, children: [_jsxs("div", { ref: area, className: "flood-scatter-chart__area", "aria-hidden": "true", children: [inspecting && _jsx("span", { className: "flood-scatter-chart__guides", style: { "--flood-scatter-x": `${active.x * 100}%`, "--flood-scatter-y": `${active.y * 100}%` } }), data.positions.map(({ point, x, y }, i) => _jsx("span", { className: "flood-scatter-chart__point", "data-active": inspecting && i === index || undefined, style: { left: `${x * 100}%`, top: `${y * 100}%` } }, point.id))] }), inspecting && _jsx(Surface, { ref: tooltip, className: "flood-chart__tooltip flood-scatter-chart__tooltip", elevation: "raised", padding: "2", "aria-hidden": "true", children: _jsxs("div", { className: "flood-scatter-chart__tooltip-content", children: [active.point.label && _jsx(Text, { size: "helper", dir: "auto", "data-point-label": "", children: active.point.label }), _jsx(Text, { size: "helper", tone: "subtle", dir: "auto", children: xLabel }), _jsx(Text, { size: "helper", "data-coordinate": "", children: xText }), _jsx(Text, { size: "helper", tone: "subtle", dir: "auto", children: yLabel }), _jsx(Text, { size: "helper", "data-coordinate": "", children: yText })] }) })] }), _jsx("div", { className: "flood-scatter-chart__ticks flood-scatter-chart__ticks--x", "aria-hidden": "true", children: ticks(data.xScale).map(tick => _jsx(Text, { size: "helper", tone: "subtle", children: formatX(tick) }, tick)) }), _jsx(Text, { className: "flood-scatter-chart__horizontal-label", size: "helper", tone: "subtle", "aria-hidden": "true", dir: "auto", children: xLabel })] })] }) });
170
+ });
@@ -0,0 +1,2 @@
1
+ import { type SparklineProps } from "./index.js";
2
+ export declare const TrendPlot: import("react").ForwardRefExoticComponent<SparklineProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,65 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useId } from "react";
3
+ import { Text } from "./index.js";
4
+ const plotWidth = 96;
5
+ const plotHeight = 32;
6
+ const plotInset = 3;
7
+ function isObservation(value) {
8
+ return typeof value === "number" && Number.isFinite(value);
9
+ }
10
+ function getPlot(values, domain, area) {
11
+ let observedMin = Infinity;
12
+ let observedMax = -Infinity;
13
+ for (const value of values) {
14
+ if (isObservation(value)) {
15
+ observedMin = Math.min(observedMin, value);
16
+ observedMax = Math.max(observedMax, value);
17
+ }
18
+ }
19
+ if (observedMin === Infinity)
20
+ return null;
21
+ const [min, max] = domain && Number.isFinite(domain[0]) && Number.isFinite(domain[1]) && domain[0] < domain[1]
22
+ ? domain
23
+ : [observedMin, observedMax];
24
+ const span = max - min;
25
+ const commands = [];
26
+ const areas = [];
27
+ const markers = [];
28
+ let lastPoint;
29
+ let segmentStart = 0;
30
+ let segmentStartX = "";
31
+ for (const [index, value] of values.entries()) {
32
+ if (!isObservation(value))
33
+ continue;
34
+ const clamped = Math.min(max, Math.max(min, value));
35
+ // Halve the operands before subtracting if the finite bounds span more than Number.MAX_VALUE.
36
+ const fraction = span === 0 ? 0.5 : Number.isFinite(span)
37
+ ? (clamped - min) / span
38
+ : (clamped / 2 - min / 2) / (max / 2 - min / 2);
39
+ const x = values.length === 1 ? plotWidth / 2 : plotInset + index * (plotWidth - 2 * plotInset) / (values.length - 1);
40
+ const y = plotHeight - plotInset - fraction * (plotHeight - 2 * plotInset);
41
+ const continuesLine = isObservation(values[index - 1]);
42
+ const plotX = x.toFixed(2);
43
+ if (!continuesLine) {
44
+ segmentStart = commands.length;
45
+ segmentStartX = plotX;
46
+ }
47
+ commands.push(`${continuesLine ? "L" : "M"}${plotX} ${y.toFixed(2)}`);
48
+ lastPoint = { x, y, index };
49
+ if (!isObservation(values[index + 1])) {
50
+ if (!continuesLine)
51
+ markers.push(lastPoint);
52
+ else if (area)
53
+ areas.push(`${commands.slice(segmentStart).join(" ")} L${plotX} ${plotHeight} H${segmentStartX} Z`);
54
+ }
55
+ }
56
+ if (lastPoint && markers.at(-1) !== lastPoint)
57
+ markers.push(lastPoint);
58
+ return { path: commands.join(" "), area: areas.join(" "), markers, lastPoint };
59
+ }
60
+ const markerPath = ({ x, y }) => `M${x.toFixed(2)} ${y.toFixed(2)}h0`;
61
+ export const TrendPlot = /* @__PURE__ */ forwardRef(function TrendPlot({ values, domain, label, showLabel, size, tone, variant, className, ...props }, ref) {
62
+ const gradientId = useId();
63
+ const plot = getPlot(values, domain, variant === "area");
64
+ return (_jsxs("div", { ...props, ref: ref, className: ["flood-sparkline", className].filter(Boolean).join(" "), "data-size": size, "data-tone": tone, role: "img", "aria-label": label, children: [plot ? (_jsxs("svg", { className: "flood-sparkline__plot", viewBox: `0 0 ${plotWidth} ${plotHeight}`, preserveAspectRatio: "none", "aria-hidden": "true", focusable: "false", children: [plot.area && _jsxs(_Fragment, { children: [_jsx("defs", { children: _jsxs("linearGradient", { id: gradientId, x1: "0", x2: "0", y1: "0", y2: "1", children: [_jsx("stop", { stopColor: "currentColor", stopOpacity: ".42" }), _jsx("stop", { offset: "100%", stopColor: "currentColor", stopOpacity: "0" })] }) }), _jsx("path", { className: "flood-sparkline__area", d: plot.area, fill: `url(#${gradientId})` })] }), _jsx("path", { className: "flood-sparkline__line", d: plot.path }), plot.lastPoint && _jsx("path", { className: "flood-sparkline__halo", d: markerPath(plot.lastPoint) }), plot.markers.map(point => (_jsx("path", { className: "flood-sparkline__point", d: markerPath(point), "data-latest": point === plot.lastPoint || undefined }, point.index)))] })) : (_jsx(Text, { className: "flood-sparkline__empty", size: "helper", tone: "subtle", "aria-hidden": "true", children: "\u2014" })), showLabel && _jsx(Text, { className: "flood-sparkline__label", size: "helper", tone: "subtle", "aria-hidden": "true", children: label })] }));
65
+ });
@@ -0,0 +1,2 @@
1
+ import { type WaterfallChartProps } from "./index.js";
2
+ export declare const WaterfallPlot: import("react").ForwardRefExoticComponent<WaterfallChartProps & import("react").RefAttributes<HTMLDivElement>>;