@abuzareal/contour-ui 0.2.0 → 0.3.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 +35 -0
- package/COMPOSITION.md +193 -0
- package/CONTRACTS.md +93 -0
- package/CONTRIBUTING.md +2 -0
- package/NEXTJS.md +3 -1
- package/PUBLIC-API.md +231 -0
- package/README.md +11 -7
- package/RELEASING.md +1 -1
- package/dist/components/ui/Button.d.ts +10 -2
- package/dist/components/ui/Button.js +7 -3
- package/dist/components/ui/Checkbox.d.ts +7 -1
- package/dist/components/ui/Checkbox.js +14 -6
- package/dist/components/ui/DepthButton.d.ts +5 -2
- package/dist/components/ui/DepthButton.js +8 -3
- package/dist/components/ui/Dialog.d.ts +21 -3
- package/dist/components/ui/Dialog.js +17 -11
- package/dist/components/ui/ExternalLink.d.ts +3 -2
- package/dist/components/ui/ExternalLink.js +7 -3
- package/dist/components/ui/IconButton.d.ts +10 -2
- package/dist/components/ui/IconButton.js +7 -3
- package/dist/components/ui/Popover.d.ts +19 -4
- package/dist/components/ui/Popover.js +16 -9
- package/dist/components/ui/Slider.d.ts +2 -1
- package/dist/components/ui/Slider.js +5 -4
- package/dist/components/ui/Switch.d.ts +2 -1
- package/dist/components/ui/Switch.js +5 -4
- package/dist/components/ui/Tabs.d.ts +16 -4
- package/dist/components/ui/Tabs.js +46 -24
- package/dist/components/ui/ThemeToggle.d.ts +2 -1
- package/dist/components/ui/ThemeToggle.js +5 -3
- package/dist/hooks/useClipboardCopy.js +29 -6
- package/dist/hooks/useControlRef.js +2 -2
- package/dist/hooks/useTabRefs.d.ts +4 -0
- package/dist/hooks/useTabRefs.js +12 -0
- package/dist/lib/componentProps.d.ts +5 -0
- package/dist/lib/componentProps.js +1 -0
- package/dist/lib/composeEventHandlers.d.ts +4 -0
- package/dist/lib/composeEventHandlers.js +8 -0
- package/dist/lib/composeRefs.d.ts +3 -0
- package/dist/lib/composeRefs.js +24 -0
- package/package.json +5 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Native controlled modal, with refs and props for its existing DOM parts. */
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
-
|
|
3
|
+
import type { NativePartProps } from "../../lib/componentProps.js";
|
|
4
|
+
export type DialogProps = Omit<NativePartProps<"dialog">, "ref" | "open" | "onClose" | "title"> & {
|
|
4
5
|
open: boolean;
|
|
5
6
|
onClose: () => void;
|
|
6
7
|
title: string;
|
|
@@ -8,5 +9,22 @@ export type DialogProps = {
|
|
|
8
9
|
description?: string;
|
|
9
10
|
footer?: ReactNode;
|
|
10
11
|
placement?: "center" | "left" | "right";
|
|
12
|
+
panelProps?: NativePartProps<"div">;
|
|
13
|
+
bodyProps?: NativePartProps<"div">;
|
|
14
|
+
footerProps?: NativePartProps<"footer">;
|
|
15
|
+
closeButtonProps?: NativePartProps<"button">;
|
|
11
16
|
};
|
|
12
|
-
|
|
17
|
+
declare const Dialog: import("react").ForwardRefExoticComponent<Omit<NativePartProps<"dialog">, "title" | "ref" | "onClose" | "open"> & {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
title: string;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
description?: string;
|
|
23
|
+
footer?: ReactNode;
|
|
24
|
+
placement?: "center" | "left" | "right";
|
|
25
|
+
panelProps?: NativePartProps<"div">;
|
|
26
|
+
bodyProps?: NativePartProps<"div">;
|
|
27
|
+
footerProps?: NativePartProps<"footer">;
|
|
28
|
+
closeButtonProps?: NativePartProps<"button">;
|
|
29
|
+
} & import("react").RefAttributes<HTMLDialogElement>>;
|
|
30
|
+
export default Dialog;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
/**
|
|
4
|
-
import { useEffect, useId
|
|
3
|
+
/** Native controlled modal, with refs and props for its existing DOM parts. */
|
|
4
|
+
import { forwardRef, useEffect, useId } from "react";
|
|
5
5
|
import { X } from "lucide-react";
|
|
6
6
|
import { cx } from "../../lib/classNames.js";
|
|
7
7
|
import { lockDocumentScroll } from "../../lib/lockDocumentScroll.js";
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
import { composeEventHandlers } from "../../lib/composeEventHandlers.js";
|
|
9
|
+
import useControlRef from "../../hooks/useControlRef.js";
|
|
10
|
+
const Dialog = forwardRef(function Dialog({ open, onClose, title, children, description, footer, placement = "center", className, onCancel, onClick, panelProps, bodyProps, footerProps, closeButtonProps, ...rest }, forwardedRef) {
|
|
11
|
+
const { ref, attach } = useControlRef(forwardedRef);
|
|
10
12
|
const titleId = useId();
|
|
11
13
|
const descriptionId = useId();
|
|
12
14
|
useEffect(() => {
|
|
@@ -25,13 +27,17 @@ export default function Dialog({ open, onClose, title, children, description, fo
|
|
|
25
27
|
dialog.removeEventListener("close", release);
|
|
26
28
|
release();
|
|
27
29
|
};
|
|
28
|
-
}, [open]);
|
|
29
|
-
return (_jsx("dialog", { ref:
|
|
30
|
+
}, [open, ref]);
|
|
31
|
+
return (_jsx("dialog", { ...rest, ref: attach, role: "dialog", "aria-modal": open || undefined, className: cx("dialog", `dialog-${placement}`, className), "aria-labelledby": cx(titleId, rest["aria-labelledby"]), "aria-describedby": cx(description && descriptionId, rest["aria-describedby"]) || undefined, onCancel: (event) => {
|
|
32
|
+
onCancel?.(event);
|
|
33
|
+
const canceled = event.defaultPrevented;
|
|
34
|
+
// The browser must never close the element independently of the controlled open prop.
|
|
30
35
|
event.preventDefault();
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
if (!canceled)
|
|
37
|
+
onClose();
|
|
38
|
+
}, onClick: composeEventHandlers(onClick, (event) => {
|
|
34
39
|
if (event.target === event.currentTarget)
|
|
35
40
|
onClose();
|
|
36
|
-
}, children: _jsxs("div", { className: "dialog-panel", children: [_jsxs("header", { className: "dialog-header", children: [_jsxs("div", { children: [_jsx("h2", { id: titleId, children: title }), description && (_jsx("p", { id: descriptionId, className: "dialog-description", children: description }))] }), _jsx("button", { type: "button", className: "dialog-close", "aria-label": "Close dialog", onClick: onClose, children: _jsx(X, { size: 18 }) })] }), _jsx("div", { className: "dialog-body", children: children }), footer && _jsx("footer", { className: "dialog-footer", children: footer })] }) }));
|
|
37
|
-
}
|
|
41
|
+
}), children: _jsxs("div", { ...panelProps, className: cx("dialog-panel", panelProps?.className), children: [_jsxs("header", { className: "dialog-header", children: [_jsxs("div", { children: [_jsx("h2", { id: titleId, children: title }), description && (_jsx("p", { id: descriptionId, className: "dialog-description", children: description }))] }), _jsx("button", { ...closeButtonProps, type: "button", className: cx("dialog-close", closeButtonProps?.className), "aria-label": closeButtonProps?.["aria-label"] ?? "Close dialog", onClick: composeEventHandlers(closeButtonProps?.onClick, () => onClose()), children: _jsx(X, { size: 18 }) })] }), _jsx("div", { ...bodyProps, className: cx("dialog-body", bodyProps?.className), children: children }), footer && (_jsx("footer", { ...footerProps, className: cx("dialog-footer", footerProps?.className), children: footer }))] }) }));
|
|
42
|
+
});
|
|
43
|
+
export default Dialog;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Anchor for off-site destinations; always opens a new tab without leaking the opener. */
|
|
2
|
-
import type
|
|
2
|
+
import { type AnchorHTMLAttributes } from "react";
|
|
3
3
|
export type ExternalLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel">;
|
|
4
|
-
|
|
4
|
+
declare const ExternalLink: import("react").ForwardRefExoticComponent<ExternalLinkProps & import("react").RefAttributes<HTMLAnchorElement>>;
|
|
5
|
+
export default ExternalLink;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/** Anchor for off-site destinations; always opens a new tab without leaking the opener. */
|
|
4
|
+
import { forwardRef } from "react";
|
|
2
5
|
import { safeHref } from "../../lib/safeHref.js";
|
|
3
|
-
|
|
4
|
-
return (_jsx("a", { ...props, href: safeHref(href), target: "_blank", rel: "noopener noreferrer" }));
|
|
5
|
-
}
|
|
6
|
+
const ExternalLink = forwardRef(function ExternalLink({ href, ...props }, ref) {
|
|
7
|
+
return (_jsx("a", { ref: ref, ...props, href: safeHref(href), target: "_blank", rel: "noopener noreferrer" }));
|
|
8
|
+
});
|
|
9
|
+
export default ExternalLink;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Circular icon-only button (the portfolio's source/copy button), with a required accessible label. */
|
|
2
|
-
import type
|
|
2
|
+
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
|
3
3
|
export type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
4
4
|
label: string;
|
|
5
5
|
children: ReactNode;
|
|
@@ -8,4 +8,12 @@ export type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
|
8
8
|
/** Rotates the icon 45deg on hover, as on the project source links. */
|
|
9
9
|
spin?: boolean;
|
|
10
10
|
};
|
|
11
|
-
|
|
11
|
+
declare const IconButton: import("react").ForwardRefExoticComponent<ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
12
|
+
label: string;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
variant?: "outline" | "solid" | "accent";
|
|
15
|
+
size?: "sm" | "md" | "lg";
|
|
16
|
+
/** Rotates the icon 45deg on hover, as on the project source links. */
|
|
17
|
+
spin?: boolean;
|
|
18
|
+
} & import("react").RefAttributes<HTMLButtonElement>>;
|
|
19
|
+
export default IconButton;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
/** Circular icon-only button (the portfolio's source/copy button), with a required accessible label. */
|
|
4
|
+
import { forwardRef } from "react";
|
|
2
5
|
import { cx } from "../../lib/classNames.js";
|
|
3
|
-
|
|
4
|
-
return (_jsx("button", { ...rest, type: type, "aria-label": label, title: rest.title ?? label, className: cx("icon-button", `icon-button-${variant}`, `icon-button-${size}`, spin && "icon-button-spin", className), children: children }));
|
|
5
|
-
}
|
|
6
|
+
const IconButton = forwardRef(function IconButton({ label, children, variant = "outline", size = "md", spin = false, className, type = "button", ...rest }, ref) {
|
|
7
|
+
return (_jsx("button", { ref: ref, ...rest, type: type, "aria-label": label, title: rest.title ?? label, className: cx("icon-button", `icon-button-${variant}`, `icon-button-${size}`, spin && "icon-button-spin", className), children: children }));
|
|
8
|
+
});
|
|
9
|
+
export default IconButton;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Nonmodal local panel; root and existing trigger/panel props preserve owned state wiring. */
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import type { NativePartProps } from "../../lib/componentProps.js";
|
|
4
|
+
export type PopoverProps = Omit<NativePartProps<"div">, "ref" | "title"> & {
|
|
5
|
+
/** Content inside the owned trigger button, not a replacement interactive element. */
|
|
5
6
|
trigger: ReactNode;
|
|
6
7
|
title: string;
|
|
7
8
|
children: ReactNode;
|
|
@@ -9,5 +10,19 @@ export type PopoverProps = {
|
|
|
9
10
|
open?: boolean;
|
|
10
11
|
defaultOpen?: boolean;
|
|
11
12
|
onOpenChange?: (open: boolean) => void;
|
|
13
|
+
triggerProps?: NativePartProps<"button">;
|
|
14
|
+
panelProps?: NativePartProps<"div">;
|
|
12
15
|
};
|
|
13
|
-
|
|
16
|
+
declare const Popover: import("react").ForwardRefExoticComponent<Omit<NativePartProps<"div">, "title" | "ref"> & {
|
|
17
|
+
/** Content inside the owned trigger button, not a replacement interactive element. */
|
|
18
|
+
trigger: ReactNode;
|
|
19
|
+
title: string;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
align?: "start" | "end";
|
|
22
|
+
open?: boolean;
|
|
23
|
+
defaultOpen?: boolean;
|
|
24
|
+
onOpenChange?: (open: boolean) => void;
|
|
25
|
+
triggerProps?: NativePartProps<"button">;
|
|
26
|
+
panelProps?: NativePartProps<"div">;
|
|
27
|
+
} & import("react").RefAttributes<HTMLDivElement>>;
|
|
28
|
+
export default Popover;
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
/**
|
|
4
|
-
import { useCallback, useId
|
|
3
|
+
/** Nonmodal local panel; root and existing trigger/panel props preserve owned state wiring. */
|
|
4
|
+
import { forwardRef, useCallback, useId } from "react";
|
|
5
5
|
import useOpenState from "../../hooks/useOpenState.js";
|
|
6
6
|
import useDismiss from "../../hooks/useDismiss.js";
|
|
7
|
+
import useControlRef from "../../hooks/useControlRef.js";
|
|
7
8
|
import { cx } from "../../lib/classNames.js";
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
import { composeEventHandlers } from "../../lib/composeEventHandlers.js";
|
|
10
|
+
const Popover = forwardRef(function Popover({ trigger, title, children, align = "start", open: controlledOpen, defaultOpen = false, onOpenChange, className, triggerProps, panelProps, ...rest }, forwardedRef) {
|
|
11
|
+
const generatedPanelId = useId();
|
|
12
|
+
const panelId = panelProps?.id ?? generatedPanelId;
|
|
10
13
|
const titleId = useId();
|
|
11
14
|
const [open, setOpen] = useOpenState(controlledOpen, defaultOpen, onOpenChange);
|
|
12
|
-
const container =
|
|
13
|
-
const button =
|
|
15
|
+
const { ref: container, attach: attachContainer } = useControlRef(forwardedRef);
|
|
16
|
+
const { ref: button, attach: attachButton } = useControlRef(triggerProps?.ref ?? null);
|
|
14
17
|
useDismiss(container, open, useCallback((reason) => {
|
|
15
18
|
setOpen(false);
|
|
16
19
|
if (reason === "escape")
|
|
17
20
|
button.current?.focus();
|
|
18
|
-
}, [setOpen]));
|
|
19
|
-
return (_jsxs("div", { ref:
|
|
20
|
-
|
|
21
|
+
}, [setOpen, button]));
|
|
22
|
+
return (_jsxs("div", { ...rest, ref: attachContainer, className: cx("popover", `popover-${align}`, className), children: [_jsx("button", { ...triggerProps, ref: attachButton, type: "button", className: cx("menu-trigger", triggerProps?.className), "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": panelId, onClick: composeEventHandlers(triggerProps?.onClick, () => {
|
|
23
|
+
if (!triggerProps?.disabled)
|
|
24
|
+
setOpen(!open);
|
|
25
|
+
}), children: trigger }), _jsxs("div", { ...panelProps, id: panelId, role: "dialog", "aria-labelledby": cx(titleId, panelProps?.["aria-labelledby"]), className: cx("popover-panel", panelProps?.className), hidden: !open, children: [_jsx("strong", { id: titleId, className: "popover-title", children: title }), children] })] }));
|
|
26
|
+
});
|
|
27
|
+
export default Popover;
|
|
@@ -9,4 +9,5 @@ export type SliderProps = {
|
|
|
9
9
|
format?: (value: number) => string;
|
|
10
10
|
disabled?: boolean;
|
|
11
11
|
};
|
|
12
|
-
|
|
12
|
+
declare const Slider: import("react").ForwardRefExoticComponent<SliderProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
13
|
+
export default Slider;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
/** Range slider with a filled acid track and a live value readout. */
|
|
4
|
-
import { useId } from "react";
|
|
5
|
-
|
|
4
|
+
import { forwardRef, useId } from "react";
|
|
5
|
+
const Slider = forwardRef(function Slider({ label, value, onChange, min = 0, max = 100, step = 1, format = String, disabled, }, ref) {
|
|
6
6
|
const id = useId();
|
|
7
7
|
const progress = max > min
|
|
8
8
|
? Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100))
|
|
9
9
|
: 0;
|
|
10
|
-
return (_jsxs("div", { className: "slider", children: [_jsxs("div", { className: "slider-header mono", children: [_jsx("label", { htmlFor: id, className: "field-label", children: label }), _jsx("output", { htmlFor: id, children: format(value) })] }), _jsx("input", { id: id, type: "range", min: min, max: max, step: step, value: value, disabled: disabled, "aria-valuetext": format(value), style: { "--progress": `${progress}%` }, onChange: (event) => onChange(Number(event.target.value)) })] }));
|
|
11
|
-
}
|
|
10
|
+
return (_jsxs("div", { className: "slider", children: [_jsxs("div", { className: "slider-header mono", children: [_jsx("label", { htmlFor: id, className: "field-label", children: label }), _jsx("output", { htmlFor: id, children: format(value) })] }), _jsx("input", { ref: ref, id: id, type: "range", min: min, max: max, step: step, value: value, disabled: disabled, "aria-valuetext": format(value), style: { "--progress": `${progress}%` }, onChange: (event) => onChange(Number(event.target.value)) })] }));
|
|
11
|
+
});
|
|
12
|
+
export default Slider;
|
|
@@ -5,4 +5,5 @@ export type SwitchProps = {
|
|
|
5
5
|
description?: string;
|
|
6
6
|
disabled?: boolean;
|
|
7
7
|
};
|
|
8
|
-
|
|
8
|
+
declare const Switch: import("react").ForwardRefExoticComponent<SwitchProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
9
|
+
export default Switch;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
/** On/off switch (role="switch") with a pill track that fills acid when on. */
|
|
4
|
-
import { useId } from "react";
|
|
5
|
-
|
|
4
|
+
import { forwardRef, useId } from "react";
|
|
5
|
+
const Switch = forwardRef(function Switch({ label, checked, onChange, description, disabled }, ref) {
|
|
6
6
|
const id = useId();
|
|
7
|
-
return (_jsxs("div", { className: "switch", children: [_jsx("button", { id: id, type: "button", role: "switch", "aria-checked": checked, "aria-describedby": description ? `${id}-description` : undefined, disabled: disabled, className: "switch-track", onClick: () => onChange(!checked), children: _jsx("span", { className: "switch-thumb" }) }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: label }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description }))] })] }));
|
|
8
|
-
}
|
|
7
|
+
return (_jsxs("div", { className: "switch", children: [_jsx("button", { ref: ref, id: id, type: "button", role: "switch", "aria-checked": checked, "aria-describedby": description ? `${id}-description` : undefined, disabled: disabled, className: "switch-track", onClick: () => onChange(!checked), children: _jsx("span", { className: "switch-thumb" }) }), _jsxs("label", { htmlFor: id, className: "choice-text", children: [_jsx("span", { className: "choice-label", children: label }), description && (_jsx("span", { id: `${id}-description`, className: "choice-description", children: description }))] })] }));
|
|
8
|
+
});
|
|
9
|
+
export default Switch;
|
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Controlled/default tabs, with props and native refs for root/list/buttons/panels. */
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
+
import type { NativePartProps } from "../../lib/componentProps.js";
|
|
3
4
|
export type TabItem = {
|
|
4
5
|
id: string;
|
|
5
6
|
label: string;
|
|
6
7
|
content: ReactNode;
|
|
8
|
+
/** Native styling/events/ref; disabled-tab semantics are not introduced by this part API. */
|
|
9
|
+
buttonProps?: Omit<NativePartProps<"button">, "disabled">;
|
|
10
|
+
panelProps?: NativePartProps<"div">;
|
|
7
11
|
};
|
|
8
|
-
export type TabsProps = {
|
|
12
|
+
export type TabsProps = Omit<NativePartProps<"div">, "ref"> & {
|
|
9
13
|
label: string;
|
|
10
14
|
tabs: TabItem[];
|
|
11
15
|
defaultTabId?: string;
|
|
12
|
-
/** Controlled selection. The caller must update this after onTabChange. */
|
|
13
16
|
activeTabId?: string;
|
|
14
17
|
onTabChange?: (id: string) => void;
|
|
18
|
+
listProps?: NativePartProps<"div">;
|
|
15
19
|
};
|
|
16
|
-
|
|
20
|
+
declare const Tabs: import("react").ForwardRefExoticComponent<Omit<NativePartProps<"div">, "ref"> & {
|
|
21
|
+
label: string;
|
|
22
|
+
tabs: TabItem[];
|
|
23
|
+
defaultTabId?: string;
|
|
24
|
+
activeTabId?: string;
|
|
25
|
+
onTabChange?: (id: string) => void;
|
|
26
|
+
listProps?: NativePartProps<"div">;
|
|
27
|
+
} & import("react").RefAttributes<HTMLDivElement>>;
|
|
28
|
+
export default Tabs;
|
|
@@ -1,48 +1,70 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { createElement as _createElement } from "react";
|
|
4
|
+
/** Controlled/default tabs, with props and native refs for root/list/buttons/panels. */
|
|
5
|
+
import { forwardRef, useId, useLayoutEffect, useRef, useState, } from "react";
|
|
5
6
|
import { nextIndexForKey } from "../../lib/keyboard.js";
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import { cx } from "../../lib/classNames.js";
|
|
8
|
+
import useTabRefs from "../../hooks/useTabRefs.js";
|
|
9
|
+
const Tabs = forwardRef(function Tabs({ label, tabs, defaultTabId, activeTabId, onTabChange, listProps, className, id, ...rest }, ref) {
|
|
10
|
+
const generatedId = useId();
|
|
11
|
+
const baseId = id ?? generatedId;
|
|
8
12
|
const [activeId, setActiveId] = useState(defaultTabId ?? tabs[0]?.id ?? "");
|
|
9
13
|
const [indicator, setIndicator] = useState({ left: 0, width: 0 });
|
|
10
|
-
const tabRefs = useRef(
|
|
14
|
+
const tabRefs = useRef(new Map());
|
|
11
15
|
const activeIndex = Math.max(0, tabs.findIndex((tab) => tab.id === (activeTabId ?? activeId)));
|
|
12
16
|
const selectedId = tabs[activeIndex]?.id;
|
|
17
|
+
const buttonRefs = useTabRefs(tabs, tabRefs);
|
|
13
18
|
useLayoutEffect(() => {
|
|
14
19
|
const measure = () => {
|
|
15
|
-
const tab = tabRefs.current
|
|
16
|
-
if (tab)
|
|
17
|
-
|
|
20
|
+
const tab = selectedId === undefined ? undefined : tabRefs.current.get(selectedId);
|
|
21
|
+
if (tab) {
|
|
22
|
+
const next = { left: tab.offsetLeft, width: tab.offsetWidth };
|
|
23
|
+
setIndicator((current) => current.left === next.left && current.width === next.width
|
|
24
|
+
? current
|
|
25
|
+
: next);
|
|
26
|
+
}
|
|
18
27
|
};
|
|
19
28
|
measure();
|
|
20
29
|
window.addEventListener("resize", measure);
|
|
21
30
|
return () => window.removeEventListener("resize", measure);
|
|
22
|
-
}, [
|
|
23
|
-
|
|
31
|
+
}, [
|
|
32
|
+
selectedId,
|
|
33
|
+
tabs,
|
|
34
|
+
className,
|
|
35
|
+
rest.style,
|
|
36
|
+
listProps?.className,
|
|
37
|
+
listProps?.style,
|
|
38
|
+
]);
|
|
39
|
+
const select = (tab, focus = false) => {
|
|
24
40
|
if (activeTabId === undefined)
|
|
25
|
-
setActiveId(
|
|
26
|
-
onTabChange?.(
|
|
27
|
-
|
|
41
|
+
setActiveId(tab.id);
|
|
42
|
+
onTabChange?.(tab.id);
|
|
43
|
+
if (focus)
|
|
44
|
+
tabRefs.current.get(tab.id)?.focus();
|
|
28
45
|
};
|
|
29
|
-
|
|
46
|
+
const buttonId = (tab) => tab.buttonProps?.id ?? `${baseId}-tab-${tab.id}`;
|
|
47
|
+
const panelId = (tab) => tab.panelProps?.id ?? `${baseId}-panel-${tab.id}`;
|
|
48
|
+
return (_jsxs("div", { ...rest, ref: ref, id: id, className: cx("tabs", className), children: [_jsx("div", { ...listProps, className: cx("tabs-list", listProps?.className), role: "tablist", "aria-label": label, style: {
|
|
49
|
+
...listProps?.style,
|
|
30
50
|
"--indicator-left": `${indicator.left}px`,
|
|
31
51
|
"--indicator-width": `${indicator.width}px`,
|
|
32
52
|
}, children: tabs.map((tab, index) => {
|
|
33
53
|
const selected = tab.id === selectedId;
|
|
34
|
-
return (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
setActiveId(tab.id);
|
|
39
|
-
onTabChange?.(tab.id);
|
|
54
|
+
return (_createElement("button", { ...tab.buttonProps, key: tab.id, ref: buttonRefs[index], id: buttonId(tab), type: "button", role: "tab", "aria-selected": selected, "aria-controls": panelId(tab), tabIndex: selected ? 0 : -1, className: cx("tabs-tab", tab.buttonProps?.className), onClick: (event) => {
|
|
55
|
+
tab.buttonProps?.onClick?.(event);
|
|
56
|
+
if (!event.defaultPrevented)
|
|
57
|
+
select(tab);
|
|
40
58
|
}, onKeyDown: (event) => {
|
|
59
|
+
tab.buttonProps?.onKeyDown?.(event);
|
|
60
|
+
if (event.defaultPrevented)
|
|
61
|
+
return;
|
|
41
62
|
const next = nextIndexForKey(event.key, index, tabs.length);
|
|
42
63
|
if (next === null)
|
|
43
64
|
return;
|
|
44
65
|
event.preventDefault();
|
|
45
|
-
select(next);
|
|
46
|
-
}
|
|
47
|
-
}) }), tabs.map((tab) => (
|
|
48
|
-
}
|
|
66
|
+
select(tabs[next], true);
|
|
67
|
+
} }, tab.label));
|
|
68
|
+
}) }), tabs.map((tab) => (_createElement("div", { ...tab.panelProps, key: tab.id, id: panelId(tab), role: "tabpanel", "aria-labelledby": cx(buttonId(tab), tab.panelProps?.["aria-labelledby"]), hidden: tab.id !== selectedId, tabIndex: 0, className: cx("tabs-panel", tab.panelProps?.className) }, tab.content)))] }));
|
|
69
|
+
});
|
|
70
|
+
export default Tabs;
|
|
@@ -2,4 +2,5 @@ export type ThemeToggleProps = {
|
|
|
2
2
|
/** When false the theme switches instantly instead of animating. */
|
|
3
3
|
animate: boolean;
|
|
4
4
|
};
|
|
5
|
-
|
|
5
|
+
declare const ThemeToggle: import("react").ForwardRefExoticComponent<ThemeToggleProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
6
|
+
export default ThemeToggle;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
/** Header button that switches between the light and dark themes. */
|
|
4
|
+
import { forwardRef } from "react";
|
|
4
5
|
import { Moon, Sun } from "lucide-react";
|
|
5
6
|
import useTheme from "../../hooks/useTheme.js";
|
|
6
|
-
|
|
7
|
+
const ThemeToggle = forwardRef(function ThemeToggle({ animate }, ref) {
|
|
7
8
|
const { theme, toggle } = useTheme(animate);
|
|
8
9
|
const dark = theme === "dark";
|
|
9
|
-
return (_jsxs("button", { type: "button", className: "theme-toggle", "aria-label": "Dark theme", "aria-pressed": dark, title: dark ? "Switch to light theme" : "Switch to dark theme", onClick: (event) => toggle(event.currentTarget), children: [_jsx(Moon, { className: "theme-icon-moon", size: 16, "aria-hidden": "true" }), _jsx(Sun, { className: "theme-icon-sun", size: 17, "aria-hidden": "true" })] }));
|
|
10
|
-
}
|
|
10
|
+
return (_jsxs("button", { ref: ref, type: "button", className: "theme-toggle", "aria-label": "Dark theme", "aria-pressed": dark, title: dark ? "Switch to light theme" : "Switch to dark theme", onClick: (event) => toggle(event.currentTarget), children: [_jsx(Moon, { className: "theme-icon-moon", size: 16, "aria-hidden": "true" }), _jsx(Sun, { className: "theme-icon-sun", size: 17, "aria-hidden": "true" })] }));
|
|
11
|
+
});
|
|
12
|
+
export default ThemeToggle;
|
|
@@ -3,20 +3,43 @@
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
const resetDelay = 4000;
|
|
5
5
|
export default function useClipboardCopy(text) {
|
|
6
|
-
const [
|
|
6
|
+
const [feedback, setFeedback] = useState({
|
|
7
|
+
text,
|
|
8
|
+
status: "idle",
|
|
9
|
+
});
|
|
10
|
+
// Feedback belongs to the current value, even before effects synchronize.
|
|
11
|
+
if (feedback.text !== text)
|
|
12
|
+
setFeedback({ text, status: "idle" });
|
|
13
|
+
const status = feedback.text === text ? feedback.status : "idle";
|
|
7
14
|
const resetTimer = useRef(undefined);
|
|
8
|
-
|
|
15
|
+
const active = useRef(false);
|
|
16
|
+
const request = useRef(0);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
active.current = true;
|
|
19
|
+
return () => {
|
|
20
|
+
active.current = false;
|
|
21
|
+
request.current += 1;
|
|
22
|
+
clearTimeout(resetTimer.current);
|
|
23
|
+
};
|
|
24
|
+
}, [text]);
|
|
9
25
|
const copy = useCallback(async () => {
|
|
26
|
+
if (!active.current)
|
|
27
|
+
return;
|
|
28
|
+
const currentRequest = ++request.current;
|
|
10
29
|
clearTimeout(resetTimer.current);
|
|
11
|
-
|
|
30
|
+
setFeedback({ text, status: "copying" });
|
|
31
|
+
let outcome = "copied";
|
|
12
32
|
try {
|
|
13
33
|
await navigator.clipboard.writeText(text);
|
|
14
|
-
setStatus("copied");
|
|
15
34
|
}
|
|
16
35
|
catch {
|
|
17
|
-
|
|
36
|
+
outcome = "error";
|
|
18
37
|
}
|
|
19
|
-
|
|
38
|
+
// Clipboard writes cannot be cancelled; discard obsolete completions.
|
|
39
|
+
if (!active.current || currentRequest !== request.current)
|
|
40
|
+
return;
|
|
41
|
+
setFeedback({ text, status: outcome });
|
|
42
|
+
resetTimer.current = setTimeout(() => setFeedback({ text, status: "idle" }), resetDelay);
|
|
20
43
|
}, [text]);
|
|
21
44
|
return { status, copy };
|
|
22
45
|
}
|
|
@@ -6,8 +6,8 @@ export default function useControlRef(forwarded) {
|
|
|
6
6
|
useImperativeHandle(forwarded, () => {
|
|
7
7
|
const element = ref.current;
|
|
8
8
|
if (!element)
|
|
9
|
-
throw new Error("The
|
|
9
|
+
throw new Error("The native target must be mounted before exposing its ref.");
|
|
10
10
|
return element;
|
|
11
|
-
});
|
|
11
|
+
}, []);
|
|
12
12
|
return { ref, attach: ref };
|
|
13
13
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type RefObject } from "react";
|
|
2
|
+
import type { TabItem } from "../components/ui/Tabs.js";
|
|
3
|
+
/** Keep ID-based internal focus targets and caller button refs in the same lifecycle. */
|
|
4
|
+
export default function useTabRefs(tabs: TabItem[], targets: RefObject<Map<string, HTMLButtonElement>>): import("react").RefCallback<HTMLButtonElement>[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { composeRefs } from "../lib/composeRefs.js";
|
|
4
|
+
/** Keep ID-based internal focus targets and caller button refs in the same lifecycle. */
|
|
5
|
+
export default function useTabRefs(tabs, targets) {
|
|
6
|
+
return useMemo(() => tabs.map((tab) => composeRefs((element) => {
|
|
7
|
+
if (element)
|
|
8
|
+
targets.current?.set(tab.id, element);
|
|
9
|
+
else
|
|
10
|
+
targets.current?.delete(tab.id);
|
|
11
|
+
}, tab.buttonProps?.ref)), [tabs, targets]);
|
|
12
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, JSX } from "react";
|
|
2
|
+
/** Props customize existing owned DOM parts; children and raw markup stay component-owned. */
|
|
3
|
+
export type NativePartProps<Tag extends keyof JSX.IntrinsicElements> = Omit<ComponentPropsWithRef<Tag>, "children" | "dangerouslySetInnerHTML"> & {
|
|
4
|
+
[name: `data-${string}`]: string | number | boolean | undefined;
|
|
5
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Caller vetoes an internal action without replacing required state/ARIA wiring. */
|
|
2
|
+
export declare function composeEventHandlers<Event extends {
|
|
3
|
+
defaultPrevented: boolean;
|
|
4
|
+
}>(caller: ((event: Event) => void) | undefined, internal: (event: Event) => void): (event: Event) => void;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Retain internal nodes and caller refs, honoring React 19 cleanup callbacks on detach. */
|
|
2
|
+
export function composeRefs(...refs) {
|
|
3
|
+
let cleanups = [];
|
|
4
|
+
return (element) => {
|
|
5
|
+
for (const cleanup of cleanups)
|
|
6
|
+
cleanup();
|
|
7
|
+
cleanups = [];
|
|
8
|
+
if (element === null)
|
|
9
|
+
return;
|
|
10
|
+
for (const ref of refs) {
|
|
11
|
+
if (typeof ref === "function") {
|
|
12
|
+
const cleanup = ref(element);
|
|
13
|
+
cleanups.push(typeof cleanup === "function" ? () => cleanup() : () => ref(null));
|
|
14
|
+
}
|
|
15
|
+
else if (ref) {
|
|
16
|
+
// RefObject is read-only in React 18's consumer types; assigning is React's ref contract.
|
|
17
|
+
Object.assign(ref, { current: element });
|
|
18
|
+
cleanups.push(() => {
|
|
19
|
+
Object.assign(ref, { current: null });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abuzareal/contour-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Editorial React components, themes, restrained motion, and optional Three.js objects.",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
"RELEASING.md",
|
|
14
14
|
"CONTRIBUTING.md",
|
|
15
15
|
"CODE_OF_CONDUCT.md",
|
|
16
|
-
"NEXTJS.md"
|
|
16
|
+
"NEXTJS.md",
|
|
17
|
+
"CONTRACTS.md",
|
|
18
|
+
"PUBLIC-API.md",
|
|
19
|
+
"COMPOSITION.md"
|
|
17
20
|
],
|
|
18
21
|
"sideEffects": [
|
|
19
22
|
"**/*.css"
|