@abuzareal/contour-ui 0.4.0 → 0.5.0
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/CHANGELOG.md +12 -0
- package/CONTRACTS.md +7 -1
- package/NEXTJS.md +3 -1
- package/OVERLAYS.md +52 -0
- package/PUBLIC-API.md +179 -162
- package/README.md +40 -26
- package/RELEASING.md +1 -1
- package/SIZE.md +45 -0
- package/STYLING.md +68 -0
- package/dist/actions.css +7 -0
- package/dist/components/hero/Sculpture.js +2 -1
- package/dist/components/three/ObjectStudy.js +2 -1
- package/dist/components/ui/Alert.d.ts +2 -1
- package/dist/components/ui/Alert.js +3 -3
- package/dist/components/ui/Avatar.d.ts +3 -1
- package/dist/components/ui/Avatar.js +3 -3
- package/dist/components/ui/Badge.js +2 -2
- package/dist/components/ui/BrandMark.js +2 -1
- package/dist/components/ui/Breadcrumbs.d.ts +2 -1
- package/dist/components/ui/Breadcrumbs.js +3 -2
- package/dist/components/ui/Button.d.ts +2 -0
- package/dist/components/ui/Button.js +3 -2
- package/dist/components/ui/Card.js +2 -2
- package/dist/components/ui/Checkbox.js +3 -3
- package/dist/components/ui/CountUp.d.ts +2 -1
- package/dist/components/ui/CountUp.js +6 -3
- package/dist/components/ui/DataTable.d.ts +1 -0
- package/dist/components/ui/DataTable.js +12 -8
- package/dist/components/ui/DepthButton.js +2 -2
- package/dist/components/ui/Dialog.d.ts +5 -1
- package/dist/components/ui/Dialog.js +49 -18
- package/dist/components/ui/Disclosure.js +2 -1
- package/dist/components/ui/Divider.js +3 -2
- package/dist/components/ui/DropdownMenu.d.ts +11 -4
- package/dist/components/ui/DropdownMenu.js +109 -66
- package/dist/components/ui/EmptyState.js +2 -1
- package/dist/components/ui/Field.js +2 -2
- package/dist/components/ui/FlipCard.js +2 -2
- package/dist/components/ui/FloatingPortal.d.ts +10 -0
- package/dist/components/ui/FloatingPortal.js +51 -0
- package/dist/components/ui/IconButton.js +2 -2
- package/dist/components/ui/Kbd.js +2 -1
- package/dist/components/ui/Magnetic.js +2 -1
- package/dist/components/ui/Marquee.js +3 -2
- package/dist/components/ui/OverlayTrigger.d.ts +11 -0
- package/dist/components/ui/OverlayTrigger.js +5 -0
- package/dist/components/ui/Pagination.d.ts +5 -1
- package/dist/components/ui/Pagination.js +3 -2
- package/dist/components/ui/Popover.d.ts +18 -8
- package/dist/components/ui/Popover.js +58 -17
- package/dist/components/ui/ProgressBar.js +2 -2
- package/dist/components/ui/RadioGroup.js +5 -5
- package/dist/components/ui/Reveal.d.ts +0 -1
- package/dist/components/ui/Reveal.js +4 -3
- package/dist/components/ui/RevealHeading.js +2 -1
- package/dist/components/ui/ScrollProgress.js +2 -1
- package/dist/components/ui/SectionLabel.js +2 -1
- package/dist/components/ui/SegmentedControl.js +6 -3
- package/dist/components/ui/SelectField.js +3 -2
- package/dist/components/ui/Skeleton.js +2 -2
- package/dist/components/ui/Slider.js +3 -3
- package/dist/components/ui/Spinner.js +2 -2
- package/dist/components/ui/Stat.js +2 -1
- package/dist/components/ui/Stepper.js +3 -3
- package/dist/components/ui/Switch.d.ts +2 -0
- package/dist/components/ui/Switch.js +6 -7
- package/dist/components/ui/Tabs.js +7 -5
- package/dist/components/ui/Tag.d.ts +2 -1
- package/dist/components/ui/Tag.js +4 -3
- package/dist/components/ui/TextArea.js +2 -1
- package/dist/components/ui/TextField.js +3 -2
- package/dist/components/ui/ThemeScope.d.ts +13 -0
- package/dist/components/ui/ThemeScope.js +10 -0
- package/dist/components/ui/ThemeToggle.d.ts +3 -0
- package/dist/components/ui/ThemeToggle.js +4 -3
- package/dist/components/ui/TiltCard.js +3 -2
- package/dist/components/ui/Timeline.js +2 -2
- package/dist/components/ui/ToastProvider.d.ts +5 -2
- package/dist/components/ui/ToastProvider.js +5 -5
- package/dist/components/ui/Tooltip.d.ts +4 -4
- package/dist/components/ui/Tooltip.js +70 -10
- package/dist/data-display.css +5 -0
- package/dist/effects.css +6 -0
- package/dist/feedback.css +5 -0
- package/dist/fonts-latin.css +10 -4
- package/dist/fonts.css +8 -0
- package/dist/forms.css +5 -0
- package/dist/hooks/useDismiss.d.ts +12 -3
- package/dist/hooks/useDismiss.js +5 -21
- package/dist/hooks/useFloatingPanel.d.ts +14 -0
- package/dist/hooks/useFloatingPanel.js +157 -0
- package/dist/hooks/useLayerDismiss.d.ts +4 -0
- package/dist/hooks/useLayerDismiss.js +48 -0
- package/dist/hooks/useMobileMenu.d.ts +0 -1
- package/dist/hooks/useMobileMenu.js +11 -16
- package/dist/hooks/usePanelFocus.d.ts +10 -0
- package/dist/hooks/usePanelFocus.js +60 -0
- package/dist/hooks/useTheme.d.ts +1 -0
- package/dist/hooks/useTheme.js +18 -5
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1 -0
- package/dist/kit/forms.css +11 -2
- package/dist/kit/navigation.css +4 -0
- package/dist/kit/overlays.css +8 -4
- package/dist/lib/classNames.d.ts +2 -0
- package/dist/lib/classNames.js +16 -1
- package/dist/lib/componentClasses.d.ts +1 -0
- package/dist/lib/componentClasses.js +2 -0
- package/dist/lib/keyboard.d.ts +1 -1
- package/dist/lib/keyboard.js +5 -2
- package/dist/lib/layers.d.ts +18 -0
- package/dist/lib/layers.js +128 -0
- package/dist/lib/themeScope.d.ts +5 -0
- package/dist/lib/themeScope.js +3 -0
- package/dist/minimal-reset.css +6 -0
- package/dist/navigation.css +5 -0
- package/dist/overlays.css +5 -0
- package/dist/scoped/base.css +183 -0
- package/dist/scoped/kit/actions.css +209 -0
- package/dist/scoped/kit/data-display.css +492 -0
- package/dist/scoped/kit/effects.css +140 -0
- package/dist/scoped/kit/feedback.css +309 -0
- package/dist/scoped/kit/forms.css +370 -0
- package/dist/scoped/kit/navigation.css +266 -0
- package/dist/scoped/kit/overlays.css +299 -0
- package/dist/scoped/marquee.css +58 -0
- package/dist/scoped/spinner.css +20 -0
- package/dist/scoped/theme-toggle.css +38 -0
- package/dist/scoped/tokens.css +119 -0
- package/dist/scoped/ui.css +127 -0
- package/dist/scoped/utilities.css +17 -0
- package/dist/scoped-tokens.css +1 -0
- package/dist/scoped.css +12 -0
- package/dist/theme-toggle.css +2 -2
- package/dist/tokens.json +710 -0
- package/package.json +26 -5
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ccx } from "../../lib/classNames.js";
|
|
3
4
|
/** Header button that switches between the light and dark themes. */
|
|
4
5
|
import { forwardRef } from "react";
|
|
5
6
|
import { Moon, Sun } from "lucide-react";
|
|
6
7
|
import useTheme from "../../hooks/useTheme.js";
|
|
7
|
-
const ThemeToggle = forwardRef(function ThemeToggle({ animate }, ref) {
|
|
8
|
-
const { theme, toggle } = useTheme(animate);
|
|
8
|
+
const ThemeToggle = forwardRef(function ThemeToggle({ animate, darkLabel = "Dark theme", lightTitle = "Switch to light theme", darkTitle = "Switch to dark theme", }, ref) {
|
|
9
|
+
const { theme, toggle, canToggle } = useTheme(animate);
|
|
9
10
|
const dark = theme === "dark";
|
|
10
|
-
return (_jsxs("button", { ref: ref, type: "button", className: "theme-toggle", "aria-label":
|
|
11
|
+
return (_jsxs("button", { ref: ref, type: "button", className: ccx("theme-toggle"), "aria-label": darkLabel, "aria-pressed": dark, disabled: !canToggle, title: dark ? lightTitle : darkTitle, onClick: (event) => toggle(event.currentTarget), children: [_jsx(Moon, { className: ccx("theme-icon-moon"), size: 16, "aria-hidden": "true" }), _jsx(Sun, { className: ccx("theme-icon-sun"), size: 17, "aria-hidden": "true" })] }));
|
|
11
12
|
});
|
|
12
13
|
export default ThemeToggle;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ccx } from "../../lib/classNames.js";
|
|
3
4
|
/** Card that tilts in 3D toward a fine pointer with a moving glare highlight. */
|
|
4
5
|
import { useRef } from "react";
|
|
5
6
|
import { motionDisabledFor } from "../../lib/motion.js";
|
|
6
7
|
export default function TiltCard({ children, maxTilt = 12 }) {
|
|
7
8
|
const ref = useRef(null);
|
|
8
|
-
return (_jsx("div", { className: "tilt-stage", children: _jsxs("div", { ref: ref, className: "tilt-card", onPointerMove: (event) => {
|
|
9
|
+
return (_jsx("div", { className: ccx("tilt-stage"), children: _jsxs("div", { ref: ref, className: ccx("tilt-card"), onPointerMove: (event) => {
|
|
9
10
|
const element = ref.current;
|
|
10
11
|
if (!element ||
|
|
11
12
|
event.pointerType !== "mouse" ||
|
|
@@ -20,5 +21,5 @@ export default function TiltCard({ children, maxTilt = 12 }) {
|
|
|
20
21
|
element.style.setProperty("--glare-y", `${y * 100}%`);
|
|
21
22
|
}, onPointerLeave: () => {
|
|
22
23
|
["--tilt-x", "--tilt-y", "--glare-x", "--glare-y"].forEach((name) => ref.current?.style.removeProperty(name));
|
|
23
|
-
}, children: [children, _jsx("span", { className: "tilt-glare", "aria-hidden": "true" })] }) }));
|
|
24
|
+
}, children: [children, _jsx("span", { className: ccx("tilt-glare"), "aria-hidden": "true" })] }) }));
|
|
24
25
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/** Vertical timeline of dated entries joined by a rule, with an acid marker for the current one. */
|
|
3
|
-
import {
|
|
3
|
+
import { ccx } from "../../lib/classNames.js";
|
|
4
4
|
export default function Timeline({ entries }) {
|
|
5
|
-
return (_jsx("ol", { className: "timeline", children: entries.map((entry) => (_jsxs("li", { className:
|
|
5
|
+
return (_jsx("ol", { className: ccx("timeline"), children: entries.map((entry) => (_jsxs("li", { className: ccx("timeline-entry", entry.current && "timeline-current"), children: [_jsx("span", { className: ccx("timeline-date mono"), children: entry.date }), _jsx("span", { className: ccx("timeline-title"), children: entry.title }), entry.body && (_jsx("span", { className: ccx("timeline-body"), children: entry.body }))] }, `${entry.date}-${entry.title}`))) }));
|
|
6
6
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/** Renders queued toasts in a polite live region; each auto-dismisses unless hovered or focused. */
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
-
export
|
|
3
|
+
export type ToastProviderProps = {
|
|
4
4
|
children: ReactNode;
|
|
5
|
-
|
|
5
|
+
label?: string;
|
|
6
|
+
dismissLabel?: string;
|
|
7
|
+
};
|
|
8
|
+
export default function ToastProvider({ children, label, dismissLabel, }: ToastProviderProps): import("react").JSX.Element;
|
|
@@ -4,9 +4,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
5
5
|
import { X } from "lucide-react";
|
|
6
6
|
import { ToastContext, } from "../../hooks/useToast.js";
|
|
7
|
-
import {
|
|
7
|
+
import { ccx } from "../../lib/classNames.js";
|
|
8
8
|
const autoDismissAfter = 5000;
|
|
9
|
-
function Toast({ toast, onDismiss, }) {
|
|
9
|
+
function Toast({ toast, onDismiss, dismissLabel, }) {
|
|
10
10
|
const [paused, setPaused] = useState(false);
|
|
11
11
|
useEffect(() => {
|
|
12
12
|
if (paused)
|
|
@@ -14,9 +14,9 @@ function Toast({ toast, onDismiss, }) {
|
|
|
14
14
|
const timer = window.setTimeout(() => onDismiss(toast.id), autoDismissAfter);
|
|
15
15
|
return () => window.clearTimeout(timer);
|
|
16
16
|
}, [paused, toast.id, onDismiss]);
|
|
17
|
-
return (_jsxs("li", { className:
|
|
17
|
+
return (_jsxs("li", { className: ccx("toast", `toast-${toast.tone ?? "neutral"}`), onPointerEnter: () => setPaused(true), onPointerLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false), children: [_jsx("span", { className: ccx("toast-bar"), "aria-hidden": "true" }), _jsxs("div", { className: ccx("toast-content"), children: [_jsx("strong", { children: toast.title }), toast.message && _jsx("p", { children: toast.message })] }), _jsx("button", { type: "button", className: ccx("toast-close"), "aria-label": dismissLabel, onClick: () => onDismiss(toast.id), children: _jsx(X, { size: 15 }) })] }));
|
|
18
18
|
}
|
|
19
|
-
export default function ToastProvider({ children }) {
|
|
19
|
+
export default function ToastProvider({ children, label = "Notifications", dismissLabel = "Dismiss notification", }) {
|
|
20
20
|
const [toasts, setToasts] = useState([]);
|
|
21
21
|
const nextId = useRef(0);
|
|
22
22
|
const dismiss = useCallback((id) => setToasts((current) => current.filter((toast) => toast.id !== id)), []);
|
|
@@ -26,5 +26,5 @@ export default function ToastProvider({ children }) {
|
|
|
26
26
|
{ ...toast, id: (nextId.current += 1) },
|
|
27
27
|
]),
|
|
28
28
|
}), []);
|
|
29
|
-
return (_jsxs(ToastContext.Provider, { value: value, children: [children, _jsx("ol", { className: "toast-viewport", "aria-live": "polite", "aria-label":
|
|
29
|
+
return (_jsxs(ToastContext.Provider, { value: value, children: [children, _jsx("ol", { className: ccx("toast-viewport"), "aria-live": "polite", "aria-label": label, children: toasts.map((toast) => (_jsx(Toast, { toast: toast, onDismiss: dismiss, dismissLabel: dismissLabel }, toast.id))) })] }));
|
|
30
30
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
/** Ink tooltip shown on hover or focus, dismissible with Escape, described via aria-describedby. */
|
|
2
1
|
import { type ReactElement } from "react";
|
|
3
|
-
|
|
2
|
+
import type { FloatingOptions } from "../../hooks/useFloatingPanel.js";
|
|
3
|
+
export type TooltipProps = Omit<FloatingOptions, "side"> & {
|
|
4
4
|
content: string;
|
|
5
|
-
/**
|
|
5
|
+
/** One focusable element. Supplemental noninteractive text only. */
|
|
6
6
|
children: ReactElement<{
|
|
7
7
|
"aria-describedby"?: string;
|
|
8
8
|
}>;
|
|
9
9
|
placement?: "top" | "bottom" | "left" | "right";
|
|
10
10
|
};
|
|
11
|
-
export default function Tooltip({ content, children, placement, }: TooltipProps): import("react").JSX.Element;
|
|
11
|
+
export default function Tooltip({ content, children, placement, align, sideOffset, collisionPadding, portal, container, }: TooltipProps): import("react").JSX.Element;
|
|
@@ -1,17 +1,77 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
3
|
+
import { useContext, cloneElement, useMemo, useEffect, useId, useRef, useState, } from "react";
|
|
4
|
+
import { composeRefs } from "../../lib/composeRefs.js";
|
|
5
|
+
import { ccx } from "../../lib/classNames.js";
|
|
6
|
+
import { LayerVisibility } from "../../lib/layers.js";
|
|
7
|
+
import useDismiss from "../../hooks/useLayerDismiss.js";
|
|
8
|
+
import FloatingPortal from "./FloatingPortal.js";
|
|
9
|
+
export default function Tooltip({ content, children, placement = "top", align = "center", sideOffset = 10, collisionPadding = 8, portal = true, container, }) {
|
|
7
10
|
const id = useId();
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const anchor = useRef(null), panel = useRef(null);
|
|
12
|
+
const [anchorElement, setAnchorElement] = useState(null);
|
|
13
|
+
const attachAnchor = useMemo(() => composeRefs(anchor, setAnchorElement), []);
|
|
14
|
+
const [hovered, setHovered] = useState(false), [focused, setFocused] = useState(false);
|
|
15
|
+
const [dismissed, setDismissed] = useState(false);
|
|
16
|
+
const parentVisible = useContext(LayerVisibility);
|
|
17
|
+
const open = (hovered || focused) && !dismissed && parentVisible;
|
|
18
|
+
const timer = useRef(undefined);
|
|
19
|
+
const clear = () => {
|
|
20
|
+
clearTimeout(timer.current);
|
|
21
|
+
timer.current = undefined;
|
|
22
|
+
};
|
|
23
|
+
const enter = () => {
|
|
24
|
+
clear();
|
|
25
|
+
setHovered(true);
|
|
26
|
+
if (!focused && !hovered)
|
|
27
|
+
setDismissed(false);
|
|
28
|
+
};
|
|
29
|
+
const leave = () => {
|
|
30
|
+
clear();
|
|
31
|
+
timer.current = setTimeout(() => {
|
|
32
|
+
setHovered(false);
|
|
33
|
+
}, 160);
|
|
34
|
+
};
|
|
35
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!hovered)
|
|
38
|
+
return;
|
|
39
|
+
const doc = anchor.current?.ownerDocument;
|
|
40
|
+
const move = (event) => {
|
|
41
|
+
const a = anchor.current?.getBoundingClientRect(), p = panel.current?.getBoundingClientRect();
|
|
42
|
+
if (!a || !p)
|
|
43
|
+
return;
|
|
44
|
+
// Keep the pointer corridor between trigger and hint available, including slow travel.
|
|
45
|
+
const inside = event.clientX >= Math.min(a.left, p.left) - 4 &&
|
|
46
|
+
event.clientX <= Math.max(a.right, p.right) + 4 &&
|
|
47
|
+
event.clientY >= Math.min(a.top, p.top) - 4 &&
|
|
48
|
+
event.clientY <= Math.max(a.bottom, p.bottom) + 4;
|
|
49
|
+
if (inside)
|
|
50
|
+
clear();
|
|
51
|
+
else
|
|
52
|
+
leave();
|
|
53
|
+
};
|
|
54
|
+
doc?.addEventListener("pointermove", move);
|
|
55
|
+
return () => doc?.removeEventListener("pointermove", move);
|
|
56
|
+
}, [hovered]);
|
|
57
|
+
useDismiss(anchor, open, () => setDismissed(true), {
|
|
58
|
+
branches: [panel],
|
|
59
|
+
layerElement: panel,
|
|
60
|
+
});
|
|
61
|
+
return (_jsxs("span", { ref: attachAnchor, className: ccx("tooltip-anchor"), onPointerEnter: enter, onPointerLeave: leave, onFocus: () => {
|
|
62
|
+
setFocused(true);
|
|
63
|
+
if (!hovered)
|
|
64
|
+
setDismissed(false);
|
|
65
|
+
}, onBlur: () => setFocused(false), children: [cloneElement(children, {
|
|
13
66
|
"aria-describedby": [children.props["aria-describedby"], id]
|
|
14
67
|
.filter(Boolean)
|
|
15
68
|
.join(" "),
|
|
16
|
-
}), _jsx(
|
|
69
|
+
}), _jsx(FloatingPortal, { anchorElement: anchorElement, anchor: anchor, panel: panel, open: open, options: {
|
|
70
|
+
side: placement,
|
|
71
|
+
align,
|
|
72
|
+
sideOffset,
|
|
73
|
+
collisionPadding,
|
|
74
|
+
portal,
|
|
75
|
+
container,
|
|
76
|
+
}, children: _jsx("span", { ref: panel, id: id, role: "tooltip", className: ccx("tooltip mono", `tooltip-${placement}`, open && "is-open"), onPointerEnter: enter, onPointerLeave: leave, children: content }) })] }));
|
|
17
77
|
}
|
package/dist/effects.css
ADDED
package/dist/fonts-latin.css
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
font-style: normal;
|
|
5
5
|
font-display: swap;
|
|
6
6
|
font-weight: 100 1000;
|
|
7
|
-
src: url("./fonts/dm-sans-latin-wght-normal.woff2")
|
|
8
|
-
format("woff2-variations");
|
|
7
|
+
src: url("./fonts/dm-sans-latin-wght-normal.woff2") format("woff2-variations");
|
|
9
8
|
unicode-range:
|
|
10
9
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
|
11
10
|
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
|
@@ -30,6 +29,13 @@
|
|
|
30
29
|
font-style: normal;
|
|
31
30
|
font-display: swap;
|
|
32
31
|
font-weight: 400;
|
|
33
|
-
src: url("./fonts/dm-mono-latin-400-normal.woff2")
|
|
34
|
-
|
|
32
|
+
src: url("./fonts/dm-mono-latin-400-normal.woff2") format("woff2");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Explicit branded typography for isolated components; import after scoped styles. */
|
|
36
|
+
:where(:root) {
|
|
37
|
+
--contour-font-sans: "DM Sans Variable", "DM Sans", system-ui, sans-serif;
|
|
38
|
+
--contour-font-display:
|
|
39
|
+
"Space Grotesk Variable", "Space Grotesk", system-ui, sans-serif;
|
|
40
|
+
--contour-font-mono: "DM Mono", ui-monospace, monospace;
|
|
35
41
|
}
|
package/dist/fonts.css
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
@import "@fontsource-variable/dm-sans/wght.css";
|
|
2
2
|
@import "@fontsource-variable/space-grotesk/wght.css";
|
|
3
3
|
@import "@fontsource/dm-mono/400.css";
|
|
4
|
+
|
|
5
|
+
/* Explicit branded typography for isolated components; import after scoped styles. */
|
|
6
|
+
:where(:root) {
|
|
7
|
+
--contour-font-sans: "DM Sans Variable", "DM Sans", system-ui, sans-serif;
|
|
8
|
+
--contour-font-display:
|
|
9
|
+
"Space Grotesk Variable", "Space Grotesk", system-ui, sans-serif;
|
|
10
|
+
--contour-font-mono: "DM Mono", ui-monospace, monospace;
|
|
11
|
+
}
|
package/dist/forms.css
ADDED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
export
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import type { DismissReason } from "../lib/layers.js";
|
|
3
|
+
export type DismissOptions = {
|
|
4
|
+
/** Additional owned elements, including portaled content and the trigger. */
|
|
5
|
+
layerElement?: RefObject<HTMLElement | null>;
|
|
6
|
+
branches?: RefObject<HTMLElement | null>[];
|
|
7
|
+
/** Native modal backdrops own outside-click behavior. */
|
|
8
|
+
outside?: boolean;
|
|
9
|
+
};
|
|
10
|
+
/** Topmost layer dismissal in the container's document; caller cancellation is respected. */
|
|
11
|
+
/** Preserves the public void-returning dismissal hook; built-in layers use an internal handle. */
|
|
12
|
+
export default function useDismiss(container: RefObject<HTMLElement | null>, active: boolean, onDismiss: (reason: DismissReason, event: KeyboardEvent | PointerEvent) => void, options?: DismissOptions): void;
|
package/dist/hooks/useDismiss.js
CHANGED
|
@@ -1,23 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return;
|
|
8
|
-
const onKeyDown = (event) => {
|
|
9
|
-
if (event.key === "Escape")
|
|
10
|
-
onDismiss("escape");
|
|
11
|
-
};
|
|
12
|
-
const onPointerDown = (event) => {
|
|
13
|
-
if (!container.current?.contains(event.target))
|
|
14
|
-
onDismiss("outside");
|
|
15
|
-
};
|
|
16
|
-
document.addEventListener("keydown", onKeyDown);
|
|
17
|
-
document.addEventListener("pointerdown", onPointerDown);
|
|
18
|
-
return () => {
|
|
19
|
-
document.removeEventListener("keydown", onKeyDown);
|
|
20
|
-
document.removeEventListener("pointerdown", onPointerDown);
|
|
21
|
-
};
|
|
22
|
-
}, [container, active, onDismiss]);
|
|
2
|
+
import useLayerDismiss from "./useLayerDismiss.js";
|
|
3
|
+
/** Topmost layer dismissal in the container's document; caller cancellation is respected. */
|
|
4
|
+
/** Preserves the public void-returning dismissal hook; built-in layers use an internal handle. */
|
|
5
|
+
export default function useDismiss(container, active, onDismiss, options = {}) {
|
|
6
|
+
useLayerDismiss(container, active, onDismiss, options);
|
|
23
7
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
export type FloatingOptions = {
|
|
3
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
4
|
+
align?: "start" | "center" | "end";
|
|
5
|
+
sideOffset?: number;
|
|
6
|
+
collisionPadding?: number;
|
|
7
|
+
/** false keeps content inline; default portals into its owner body or active modal with inherited theme variables. */
|
|
8
|
+
portal?: boolean;
|
|
9
|
+
/** Must belong to the trigger's document and be inside the active modal, when applicable. */
|
|
10
|
+
container?: HTMLElement | null;
|
|
11
|
+
};
|
|
12
|
+
export declare function floatingContainer(anchor: HTMLElement, options: FloatingOptions): HTMLElement;
|
|
13
|
+
/** DOM adapter: clean up observers and ignore obsolete asynchronous positioning results. */
|
|
14
|
+
export default function useFloatingPanel(anchor: RefObject<HTMLElement | null>, panel: RefObject<HTMLElement | null>, open: boolean, { side, align, sideOffset, collisionPadding, container, }: FloatingOptions): void;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useLayoutEffect } from "react";
|
|
3
|
+
import { autoUpdate, computePosition, flip, offset, shift, size, platform, } from "@floating-ui/dom";
|
|
4
|
+
export function floatingContainer(anchor, options) {
|
|
5
|
+
// Select the enclosing native dialog before showModal runs: default-open children
|
|
6
|
+
// render in the same commit, while its open attribute is still absent.
|
|
7
|
+
const modal = anchor.closest("dialog");
|
|
8
|
+
if (options.container &&
|
|
9
|
+
options.container.ownerDocument === anchor.ownerDocument &&
|
|
10
|
+
(!modal || modal.contains(options.container)))
|
|
11
|
+
return options.container;
|
|
12
|
+
return modal ?? anchor.ownerDocument.body;
|
|
13
|
+
}
|
|
14
|
+
/** DOM adapter: clean up observers and ignore obsolete asynchronous positioning results. */
|
|
15
|
+
export default function useFloatingPanel(anchor, panel, open, { side = "bottom", align = "start", sideOffset = 8, collisionPadding = 8, container, }) {
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
const reference = anchor.current, floating = panel.current;
|
|
18
|
+
const gap = Number.isFinite(sideOffset) ? sideOffset : 8;
|
|
19
|
+
const inset = Number.isFinite(collisionPadding)
|
|
20
|
+
? Math.max(0, collisionPadding)
|
|
21
|
+
: 8;
|
|
22
|
+
if (!open || !reference || !floating)
|
|
23
|
+
return;
|
|
24
|
+
let disposed = false, revision = 0;
|
|
25
|
+
const properties = [
|
|
26
|
+
"position",
|
|
27
|
+
"left",
|
|
28
|
+
"right",
|
|
29
|
+
"top",
|
|
30
|
+
"bottom",
|
|
31
|
+
"transform",
|
|
32
|
+
"max-width",
|
|
33
|
+
"max-height",
|
|
34
|
+
"min-width",
|
|
35
|
+
];
|
|
36
|
+
const previous = properties.map((name) => ({
|
|
37
|
+
name,
|
|
38
|
+
value: floating.style.getPropertyValue(name),
|
|
39
|
+
priority: floating.style.getPropertyPriority(name),
|
|
40
|
+
}));
|
|
41
|
+
const previousPlacement = floating.getAttribute("data-placement");
|
|
42
|
+
const view = reference.ownerDocument.defaultView;
|
|
43
|
+
const update = async () => {
|
|
44
|
+
const request = ++revision;
|
|
45
|
+
if (disposed || !floating.isConnected)
|
|
46
|
+
return;
|
|
47
|
+
floating.style.position = "fixed";
|
|
48
|
+
try {
|
|
49
|
+
const scale = await platform.getScale(floating);
|
|
50
|
+
const offsetParent = await platform.getOffsetParent(floating);
|
|
51
|
+
if (disposed || request !== revision)
|
|
52
|
+
return;
|
|
53
|
+
// A window offset parent does not normalize inherited CSS zoom. Measure its
|
|
54
|
+
// rendered dimensions and convert viewport coordinates back to local CSS pixels.
|
|
55
|
+
const windowScaled = offsetParent === view && (scale.x !== 1 || scale.y !== 1);
|
|
56
|
+
const factorX = windowScaled ? scale.x : 1;
|
|
57
|
+
const factorY = windowScaled ? scale.y : 1;
|
|
58
|
+
const padding = {
|
|
59
|
+
left: inset * factorX,
|
|
60
|
+
right: inset * factorX,
|
|
61
|
+
top: inset * factorY,
|
|
62
|
+
bottom: inset * factorY,
|
|
63
|
+
};
|
|
64
|
+
const result = await computePosition(reference, floating, {
|
|
65
|
+
strategy: "fixed",
|
|
66
|
+
placement: align === "center" ? side : `${side}-${align}`,
|
|
67
|
+
platform: windowScaled
|
|
68
|
+
? {
|
|
69
|
+
...platform,
|
|
70
|
+
getDimensions(element) {
|
|
71
|
+
if (element === floating) {
|
|
72
|
+
const rect = floating.getBoundingClientRect();
|
|
73
|
+
return { width: rect.width, height: rect.height };
|
|
74
|
+
}
|
|
75
|
+
return platform.getDimensions(element);
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
: platform,
|
|
79
|
+
middleware: [
|
|
80
|
+
offset(gap * (side === "left" || side === "right" ? factorX : factorY)),
|
|
81
|
+
flip({ padding }),
|
|
82
|
+
shift({ padding, crossAxis: true }),
|
|
83
|
+
size({
|
|
84
|
+
padding,
|
|
85
|
+
apply({ availableWidth, availableHeight }) {
|
|
86
|
+
if (disposed || request !== revision)
|
|
87
|
+
return;
|
|
88
|
+
const maximumWidth = Math.max(0, availableWidth / factorX);
|
|
89
|
+
floating.style.maxWidth = `${maximumWidth}px`;
|
|
90
|
+
floating.style.maxHeight = `${Math.max(0, availableHeight / factorY)}px`;
|
|
91
|
+
// A CSS minimum must not defeat the viewport constraint at high zoom.
|
|
92
|
+
const minimum = Number.parseFloat(view?.getComputedStyle(floating).minWidth ?? "0");
|
|
93
|
+
if (Number.isFinite(minimum) && minimum > maximumWidth)
|
|
94
|
+
floating.style.minWidth = `${maximumWidth}px`;
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
if (disposed || request !== revision)
|
|
100
|
+
return;
|
|
101
|
+
Object.assign(floating.style, {
|
|
102
|
+
position: "fixed",
|
|
103
|
+
left: `${result.x / factorX}px`,
|
|
104
|
+
top: `${result.y / factorY}px`,
|
|
105
|
+
right: "auto",
|
|
106
|
+
bottom: "auto",
|
|
107
|
+
transform: "none",
|
|
108
|
+
});
|
|
109
|
+
floating.dataset.placement = result.placement;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (disposed || request !== revision)
|
|
113
|
+
return;
|
|
114
|
+
// Preserve reachable content if the owner document cannot provide geometry.
|
|
115
|
+
Object.assign(floating.style, {
|
|
116
|
+
position: "fixed",
|
|
117
|
+
left: `${inset}px`,
|
|
118
|
+
top: `${inset}px`,
|
|
119
|
+
right: "auto",
|
|
120
|
+
bottom: "auto",
|
|
121
|
+
transform: "none",
|
|
122
|
+
});
|
|
123
|
+
floating.dataset.placement = "fallback";
|
|
124
|
+
console.warn("Contour floating positioning used its viewport fallback.", error);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const cleanup = autoUpdate(reference, floating, () => {
|
|
128
|
+
void update();
|
|
129
|
+
}, {
|
|
130
|
+
elementResize: typeof view?.ResizeObserver === "function",
|
|
131
|
+
layoutShift: typeof view?.IntersectionObserver === "function",
|
|
132
|
+
});
|
|
133
|
+
return () => {
|
|
134
|
+
disposed = true;
|
|
135
|
+
cleanup();
|
|
136
|
+
for (const { name, value, priority } of previous) {
|
|
137
|
+
if (value)
|
|
138
|
+
floating.style.setProperty(name, value, priority);
|
|
139
|
+
else
|
|
140
|
+
floating.style.removeProperty(name);
|
|
141
|
+
}
|
|
142
|
+
if (previousPlacement === null)
|
|
143
|
+
floating.removeAttribute("data-placement");
|
|
144
|
+
else
|
|
145
|
+
floating.setAttribute("data-placement", previousPlacement);
|
|
146
|
+
};
|
|
147
|
+
}, [
|
|
148
|
+
anchor,
|
|
149
|
+
panel,
|
|
150
|
+
open,
|
|
151
|
+
side,
|
|
152
|
+
align,
|
|
153
|
+
sideOffset,
|
|
154
|
+
collisionPadding,
|
|
155
|
+
container,
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
import { type DismissReason } from "../lib/layers.js";
|
|
3
|
+
import type { DismissOptions } from "./useDismiss.js";
|
|
4
|
+
export default function useLayerDismiss(container: RefObject<HTMLElement | null>, active: boolean, onDismiss: (reason: DismissReason, event: KeyboardEvent | PointerEvent) => void, options?: DismissOptions): symbol;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useContext, useState, useLayoutEffect, useRef, } from "react";
|
|
3
|
+
import { LayerParent, registerLayer, refreshLayerStack, } from "../lib/layers.js";
|
|
4
|
+
export default function useLayerDismiss(container, active, onDismiss, options = {}) {
|
|
5
|
+
const [id] = useState(() => Symbol("contour-layer"));
|
|
6
|
+
const parent = useContext(LayerParent);
|
|
7
|
+
const latest = useRef({ onDismiss, options });
|
|
8
|
+
const registration = useRef(undefined);
|
|
9
|
+
// Reconcile each commit so owner-document moves rebind listeners, without reordering on updates.
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
latest.current = { onDismiss, options };
|
|
12
|
+
const doc = active ? container.current?.ownerDocument : undefined;
|
|
13
|
+
if (registration.current &&
|
|
14
|
+
(registration.current.doc !== doc ||
|
|
15
|
+
registration.current.parent !== parent)) {
|
|
16
|
+
registration.current.dispose();
|
|
17
|
+
registration.current = undefined;
|
|
18
|
+
}
|
|
19
|
+
if (!doc)
|
|
20
|
+
return;
|
|
21
|
+
if (registration.current) {
|
|
22
|
+
refreshLayerStack(doc);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
registration.current = {
|
|
26
|
+
doc,
|
|
27
|
+
parent,
|
|
28
|
+
dispose: registerLayer(doc, {
|
|
29
|
+
id,
|
|
30
|
+
parent,
|
|
31
|
+
elements: () => [
|
|
32
|
+
container.current,
|
|
33
|
+
...(latest.current.options.branches ?? []).map((ref) => ref.current),
|
|
34
|
+
],
|
|
35
|
+
floating: () => latest.current.options.layerElement?.current ?? null,
|
|
36
|
+
dismiss: (reason, event) => latest.current.onDismiss(reason, event),
|
|
37
|
+
get outside() {
|
|
38
|
+
return latest.current.options.outside !== false;
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
useLayoutEffect(() => () => {
|
|
44
|
+
registration.current?.dispose();
|
|
45
|
+
registration.current = undefined;
|
|
46
|
+
}, []);
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
/** Open state for the mobile menu, closing on Escape, outside pointer, or desktop resize. */
|
|
2
1
|
import { type RefObject } from "react";
|
|
3
2
|
export default function useMobileMenu(container: RefObject<HTMLElement | null>, toggleButton: RefObject<HTMLButtonElement | null>): readonly [boolean, import("react").Dispatch<import("react").SetStateAction<boolean>>];
|
|
@@ -1,35 +1,30 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
/** Open state for the mobile menu, closing on Escape, outside pointer, or desktop resize. */
|
|
3
|
+
import useDismiss from "./useDismiss.js";
|
|
3
4
|
import { useEffect, useState } from "react";
|
|
4
5
|
const desktopQuery = "(min-width: 768px)";
|
|
5
6
|
export default function useMobileMenu(container, toggleButton) {
|
|
6
7
|
const [open, setOpen] = useState(false);
|
|
8
|
+
useDismiss(container, open, (reason) => {
|
|
9
|
+
setOpen(false);
|
|
10
|
+
if (reason === "escape" && toggleButton.current?.isConnected)
|
|
11
|
+
toggleButton.current.focus();
|
|
12
|
+
}, { branches: [toggleButton] });
|
|
7
13
|
useEffect(() => {
|
|
8
14
|
if (!open)
|
|
9
15
|
return;
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
toggleButton.current?.focus();
|
|
15
|
-
};
|
|
16
|
-
const closeOnOutsidePointer = (event) => {
|
|
17
|
-
if (!container.current?.contains(event.target))
|
|
18
|
-
setOpen(false);
|
|
19
|
-
};
|
|
20
|
-
const desktop = window.matchMedia(desktopQuery);
|
|
16
|
+
const view = container.current?.ownerDocument.defaultView;
|
|
17
|
+
if (!view)
|
|
18
|
+
return;
|
|
19
|
+
const desktop = view.matchMedia(desktopQuery);
|
|
21
20
|
const closeOnDesktop = () => {
|
|
22
21
|
if (desktop.matches)
|
|
23
22
|
setOpen(false);
|
|
24
23
|
};
|
|
25
|
-
document.addEventListener("keydown", closeOnEscape);
|
|
26
|
-
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
|
27
24
|
desktop.addEventListener("change", closeOnDesktop);
|
|
28
25
|
return () => {
|
|
29
|
-
document.removeEventListener("keydown", closeOnEscape);
|
|
30
|
-
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
|
31
26
|
desktop.removeEventListener("change", closeOnDesktop);
|
|
32
27
|
};
|
|
33
|
-
}
|
|
28
|
+
});
|
|
34
29
|
return [open, setOpen];
|
|
35
30
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
export type PanelFocusOptions = {
|
|
3
|
+
initialFocus?: RefObject<HTMLElement | null> | false;
|
|
4
|
+
onOpenAutoFocus?: (event: Event) => void;
|
|
5
|
+
onCloseAutoFocus?: (event: Event) => void;
|
|
6
|
+
};
|
|
7
|
+
/** Reconcile actual node/document moves without treating new callback props as a close. */
|
|
8
|
+
export default function usePanelFocus(open: boolean, trigger: RefObject<HTMLElement | null>, panel: RefObject<HTMLElement | null>, reason: RefObject<{
|
|
9
|
+
value: string | undefined;
|
|
10
|
+
}>, options: PanelFocusOptions, anchorElement?: HTMLElement | null, layer?: symbol): void;
|