= (
+ { children, label },
+) => {
+ return (
+ <>
+ {React.cloneElement(children as React.VNode, {
+ // accessibility
+ "aria-hidden": "true",
+ focusable: "false", // See: https://allyjs.io/tutorials/focusing-in-svg.html#making-svg-elements-focusable
+ })}
+ {label}
+ >
+ );
+};
+
+AccessibleIcon.displayName = NAME;
+
+const Root = AccessibleIcon;
+
+export {
+ AccessibleIcon,
+ //
+ Root,
+};
+export type { AccessibleIconProps };
diff --git a/pkg/radix-ui-primitives/preact/accessible-icon/mod.ts b/pkg/radix-ui-primitives/preact/accessible-icon/mod.ts
new file mode 100644
index 0000000..3b4f759
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/accessible-icon/mod.ts
@@ -0,0 +1,6 @@
+export {
+ AccessibleIcon,
+ //
+ Root,
+} from "./AccessibleIcon.tsx";
+export type { AccessibleIconProps } from "./AccessibleIcon.tsx";
diff --git a/pkg/radix-ui-primitives/preact/accordion/Accordion.tsx b/pkg/radix-ui-primitives/preact/accordion/Accordion.tsx
new file mode 100644
index 0000000..d920ece
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/accordion/Accordion.tsx
@@ -0,0 +1,649 @@
+import * as React from "preact/compat";
+import { createContextScope } from "../context/mod.ts";
+import { createCollection } from "../collection/mod.ts";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { useControllableState } from "../use-controllable-state/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+import * as CollapsiblePrimitive from "../collapsible/mod.ts";
+import { createCollapsibleScope } from "../collapsible/mod.ts";
+import { useId } from "../id/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+import { useDirection } from "../direction/mod.ts";
+
+type Direction = "ltr" | "rtl";
+
+/* -------------------------------------------------------------------------------------------------
+ * Accordion
+ * -----------------------------------------------------------------------------------------------*/
+
+const ACCORDION_NAME = "Accordion";
+const ACCORDION_KEYS = [
+ "Home",
+ "End",
+ "ArrowDown",
+ "ArrowUp",
+ "ArrowLeft",
+ "ArrowRight",
+];
+
+const [Collection, useCollection, createCollectionScope] = createCollection<
+ AccordionTriggerElement
+>(ACCORDION_NAME);
+
+type ScopedProps = P & { __scopeAccordion?: Scope };
+const [createAccordionContext, createAccordionScope] = createContextScope(
+ ACCORDION_NAME,
+ [
+ createCollectionScope,
+ createCollapsibleScope,
+ ],
+);
+const useCollapsibleScope = createCollapsibleScope();
+
+type AccordionElement =
+ | AccordionImplMultipleElement
+ | AccordionImplSingleElement;
+interface AccordionSingleProps extends AccordionImplSingleProps {
+ type: "single";
+}
+interface AccordionMultipleProps extends AccordionImplMultipleProps {
+ type: "multiple";
+}
+
+const Accordion = React.forwardRef<
+ AccordionElement,
+ AccordionSingleProps | AccordionMultipleProps
+>(
+ (
+ props: ScopedProps,
+ forwardedRef,
+ ) => {
+ const { type, ...accordionProps } = props;
+ const singleProps = accordionProps as AccordionImplSingleProps;
+ const multipleProps = accordionProps as AccordionImplMultipleProps;
+ return (
+
+ {type === "multiple"
+ ?
+ : }
+
+ );
+ },
+);
+
+Accordion.displayName = ACCORDION_NAME;
+
+Accordion.propTypes = {
+ type(props) {
+ const value = props.value || props.defaultValue;
+ if (props.type && !["single", "multiple"].includes(props.type)) {
+ return new Error(
+ "Invalid prop `type` supplied to `Accordion`. Expected one of `single | multiple`.",
+ );
+ }
+ if (props.type === "multiple" && typeof value === "string") {
+ return new Error(
+ "Invalid prop `type` supplied to `Accordion`. Expected `single` when `defaultValue` or `value` is type `string`.",
+ );
+ }
+ if (props.type === "single" && Array.isArray(value)) {
+ return new Error(
+ "Invalid prop `type` supplied to `Accordion`. Expected `multiple` when `defaultValue` or `value` is type `string[]`.",
+ );
+ }
+ return null;
+ },
+};
+
+/* -----------------------------------------------------------------------------------------------*/
+
+type AccordionValueContextValue = {
+ value: string[];
+ onItemOpen(value: string): void;
+ onItemClose(value: string): void;
+};
+
+const [AccordionValueProvider, useAccordionValueContext] =
+ createAccordionContext(ACCORDION_NAME);
+
+const [AccordionCollapsibleProvider, useAccordionCollapsibleContext] =
+ createAccordionContext(
+ ACCORDION_NAME,
+ { collapsible: false },
+ );
+
+type AccordionImplSingleElement = AccordionImplElement;
+interface AccordionImplSingleProps extends AccordionImplProps {
+ /**
+ * The controlled stateful value of the accordion item whose content is expanded.
+ */
+ value?: string;
+ /**
+ * The value of the item whose content is expanded when the accordion is initially rendered. Use
+ * `defaultValue` if you do not need to control the state of an accordion.
+ */
+ defaultValue?: string;
+ /**
+ * The callback that fires when the state of the accordion changes.
+ */
+ onValueChange?(value: string): void;
+ /**
+ * Whether an accordion item can be collapsed after it has been opened.
+ * @default false
+ */
+ collapsible?: boolean;
+}
+
+const AccordionImplSingle = React.forwardRef<
+ AccordionImplSingleElement,
+ AccordionImplSingleProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const {
+ value: valueProp,
+ defaultValue,
+ onValueChange = () => {},
+ collapsible = false,
+ ...accordionSingleProps
+ } = props;
+
+ const [value, setValue] = useControllableState({
+ prop: valueProp,
+ defaultProp: defaultValue,
+ onChange: onValueChange,
+ });
+
+ return (
+ collapsible && setValue(""), [
+ collapsible,
+ setValue,
+ ])}
+ >
+
+
+
+
+ );
+ },
+);
+
+/* -----------------------------------------------------------------------------------------------*/
+
+type AccordionImplMultipleElement = AccordionImplElement;
+interface AccordionImplMultipleProps extends AccordionImplProps {
+ /**
+ * The controlled stateful value of the accordion items whose contents are expanded.
+ */
+ value?: string[];
+ /**
+ * The value of the items whose contents are expanded when the accordion is initially rendered. Use
+ * `defaultValue` if you do not need to control the state of an accordion.
+ */
+ defaultValue?: string[];
+ /**
+ * The callback that fires when the state of the accordion changes.
+ */
+ onValueChange?(value: string[]): void;
+}
+
+const AccordionImplMultiple = React.forwardRef<
+ AccordionImplMultipleElement,
+ AccordionImplMultipleProps
+>((props: ScopedProps, forwardedRef) => {
+ const {
+ value: valueProp,
+ defaultValue,
+ onValueChange = () => {},
+ ...accordionMultipleProps
+ } = props;
+
+ const [value = [], setValue] = useControllableState({
+ prop: valueProp,
+ defaultProp: defaultValue,
+ onChange: onValueChange,
+ });
+
+ const handleItemOpen = React.useCallback(
+ (itemValue: string) =>
+ setValue((prevValue = []) => [...prevValue, itemValue]),
+ [setValue],
+ );
+
+ const handleItemClose = React.useCallback(
+ (itemValue: string) =>
+ setValue((prevValue = []) =>
+ prevValue.filter((value) => value !== itemValue)
+ ),
+ [setValue],
+ );
+
+ return (
+
+
+
+
+
+ );
+});
+
+/* -----------------------------------------------------------------------------------------------*/
+
+type AccordionImplContextValue = {
+ disabled?: boolean;
+ direction: AccordionImplProps["dir"];
+ orientation: AccordionImplProps["orientation"];
+};
+
+const [AccordionImplProvider, useAccordionContext] = createAccordionContext<
+ AccordionImplContextValue
+>(ACCORDION_NAME);
+
+type AccordionImplElement = React.ElementRef;
+type PrimitiveDivProps = Radix.ComponentPropsWithoutRef;
+interface AccordionImplProps extends PrimitiveDivProps {
+ /**
+ * Whether or not an accordion is disabled from user interaction.
+ *
+ * @defaultValue false
+ */
+ disabled?: boolean;
+ /**
+ * The layout in which the Accordion operates.
+ * @default vertical
+ */
+ orientation?: React.AriaAttributes["aria-orientation"];
+ /**
+ * The language read direction.
+ */
+ dir?: Direction;
+}
+
+const AccordionImpl = React.forwardRef<
+ AccordionImplElement,
+ AccordionImplProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const {
+ __scopeAccordion,
+ disabled,
+ dir,
+ orientation = "vertical",
+ ...accordionProps
+ } = props;
+ const accordionRef = React.useRef(null);
+ const composedRefs = useComposedRefs(accordionRef, forwardedRef);
+ const getItems = useCollection(__scopeAccordion);
+ const direction = useDirection(dir);
+ const isDirectionLTR = direction === "ltr";
+
+ const handleKeyDown = composeEventHandlers(props.onKeyDown, (event) => {
+ if (!ACCORDION_KEYS.includes(event.key)) return;
+ const target = event.target as HTMLElement;
+ const triggerCollection = getItems().filter((item) =>
+ !item.ref.current?.disabled
+ );
+ const triggerIndex = triggerCollection.findIndex((item) =>
+ item.ref.current === target
+ );
+ const triggerCount = triggerCollection.length;
+
+ if (triggerIndex === -1) return;
+
+ // Prevents page scroll while user is navigating
+ event.preventDefault();
+
+ let nextIndex = triggerIndex;
+ const homeIndex = 0;
+ const endIndex = triggerCount - 1;
+
+ const moveNext = () => {
+ nextIndex = triggerIndex + 1;
+ if (nextIndex > endIndex) {
+ nextIndex = homeIndex;
+ }
+ };
+
+ const movePrev = () => {
+ nextIndex = triggerIndex - 1;
+ if (nextIndex < homeIndex) {
+ nextIndex = endIndex;
+ }
+ };
+
+ switch (event.key) {
+ case "Home":
+ nextIndex = homeIndex;
+ break;
+ case "End":
+ nextIndex = endIndex;
+ break;
+ case "ArrowRight":
+ if (orientation === "horizontal") {
+ if (isDirectionLTR) {
+ moveNext();
+ } else {
+ movePrev();
+ }
+ }
+ break;
+ case "ArrowDown":
+ if (orientation === "vertical") {
+ moveNext();
+ }
+ break;
+ case "ArrowLeft":
+ if (orientation === "horizontal") {
+ if (isDirectionLTR) {
+ movePrev();
+ } else {
+ moveNext();
+ }
+ }
+ break;
+ case "ArrowUp":
+ if (orientation === "vertical") {
+ movePrev();
+ }
+ break;
+ }
+
+ const clampedIndex = nextIndex % triggerCount;
+ triggerCollection[clampedIndex].ref.current?.focus();
+ });
+
+ return (
+
+
+
+
+
+ );
+ },
+);
+
+/* -------------------------------------------------------------------------------------------------
+ * AccordionItem
+ * -----------------------------------------------------------------------------------------------*/
+
+const ITEM_NAME = "AccordionItem";
+
+type AccordionItemContextValue = {
+ open?: boolean;
+ disabled?: boolean;
+ triggerId: string;
+};
+const [AccordionItemProvider, useAccordionItemContext] = createAccordionContext<
+ AccordionItemContextValue
+>(ITEM_NAME);
+
+type AccordionItemElement = React.ElementRef;
+type CollapsibleProps = Radix.ComponentPropsWithoutRef<
+ typeof CollapsiblePrimitive.Root
+>;
+interface AccordionItemProps
+ extends Omit {
+ /**
+ * Whether or not an accordion item is disabled from user interaction.
+ *
+ * @defaultValue false
+ */
+ disabled?: boolean;
+ /**
+ * A string value for the accordion item. All items within an accordion should use a unique value.
+ */
+ value: string;
+}
+
+/**
+ * `AccordionItem` contains all of the parts of a collapsible section inside of an `Accordion`.
+ */
+const AccordionItem = React.forwardRef<
+ AccordionItemElement,
+ AccordionItemProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAccordion, value, ...accordionItemProps } = props;
+ const accordionContext = useAccordionContext(ITEM_NAME, __scopeAccordion);
+ const valueContext = useAccordionValueContext(ITEM_NAME, __scopeAccordion);
+ const collapsibleScope = useCollapsibleScope(__scopeAccordion);
+ const triggerId = useId();
+ const open = (value && valueContext.value.includes(value)) || false;
+ const disabled = accordionContext.disabled || props.disabled;
+
+ return (
+
+ {
+ if (open) {
+ valueContext.onItemOpen(value);
+ } else {
+ valueContext.onItemClose(value);
+ }
+ }}
+ />
+
+ );
+ },
+);
+
+AccordionItem.displayName = ITEM_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AccordionHeader
+ * -----------------------------------------------------------------------------------------------*/
+
+const HEADER_NAME = "AccordionHeader";
+
+type AccordionHeaderElement = React.ElementRef;
+type PrimitiveHeading3Props = Radix.ComponentPropsWithoutRef<
+ typeof Primitive.h3
+>;
+interface AccordionHeaderProps extends PrimitiveHeading3Props {}
+
+/**
+ * `AccordionHeader` contains the content for the parts of an `AccordionItem` that will be visible
+ * whether or not its content is collapsed.
+ */
+const AccordionHeader = React.forwardRef<
+ AccordionHeaderElement,
+ AccordionHeaderProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAccordion, ...headerProps } = props;
+ const accordionContext = useAccordionContext(
+ ACCORDION_NAME,
+ __scopeAccordion,
+ );
+ const itemContext = useAccordionItemContext(HEADER_NAME, __scopeAccordion);
+ return (
+
+ );
+ },
+);
+
+AccordionHeader.displayName = HEADER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AccordionTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+const TRIGGER_NAME = "AccordionTrigger";
+
+type AccordionTriggerElement = React.ElementRef<
+ typeof CollapsiblePrimitive.Trigger
+>;
+type CollapsibleTriggerProps = Radix.ComponentPropsWithoutRef<
+ typeof CollapsiblePrimitive.Trigger
+>;
+interface AccordionTriggerProps extends CollapsibleTriggerProps {}
+
+/**
+ * `AccordionTrigger` is the trigger that toggles the collapsed state of an `AccordionItem`. It
+ * should always be nested inside of an `AccordionHeader`.
+ */
+const AccordionTrigger = React.forwardRef<
+ AccordionTriggerElement,
+ AccordionTriggerProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAccordion, ...triggerProps } = props;
+ const accordionContext = useAccordionContext(
+ ACCORDION_NAME,
+ __scopeAccordion,
+ );
+ const itemContext = useAccordionItemContext(TRIGGER_NAME, __scopeAccordion);
+ const collapsibleContext = useAccordionCollapsibleContext(
+ TRIGGER_NAME,
+ __scopeAccordion,
+ );
+ const collapsibleScope = useCollapsibleScope(__scopeAccordion);
+ return (
+
+
+
+ );
+ },
+);
+
+AccordionTrigger.displayName = TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AccordionContent
+ * -----------------------------------------------------------------------------------------------*/
+
+const CONTENT_NAME = "AccordionContent";
+
+type AccordionContentElement = React.ElementRef<
+ typeof CollapsiblePrimitive.Content
+>;
+type CollapsibleContentProps = Radix.ComponentPropsWithoutRef<
+ typeof CollapsiblePrimitive.Content
+>;
+interface AccordionContentProps extends CollapsibleContentProps {}
+
+/**
+ * `AccordionContent` contains the collapsible content for an `AccordionItem`.
+ */
+const AccordionContent = React.forwardRef<
+ AccordionContentElement,
+ AccordionContentProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAccordion, ...contentProps } = props;
+ const accordionContext = useAccordionContext(
+ ACCORDION_NAME,
+ __scopeAccordion,
+ );
+ const itemContext = useAccordionItemContext(CONTENT_NAME, __scopeAccordion);
+ const collapsibleScope = useCollapsibleScope(__scopeAccordion);
+ return (
+
+ );
+ },
+);
+
+AccordionContent.displayName = CONTENT_NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+function getState(open?: boolean) {
+ return open ? "open" : "closed";
+}
+
+const Root = Accordion;
+const Item = AccordionItem;
+const Header = AccordionHeader;
+const Trigger = AccordionTrigger;
+const Content = AccordionContent;
+
+export {
+ //
+ Accordion,
+ AccordionContent,
+ AccordionHeader,
+ AccordionItem,
+ AccordionTrigger,
+ Content,
+ createAccordionScope,
+ Header,
+ Item,
+ //
+ Root,
+ Trigger,
+};
+export type {
+ AccordionContentProps,
+ AccordionHeaderProps,
+ AccordionItemProps,
+ AccordionMultipleProps,
+ AccordionSingleProps,
+ AccordionTriggerProps,
+};
diff --git a/pkg/radix-ui-primitives/preact/accordion/mod.ts b/pkg/radix-ui-primitives/preact/accordion/mod.ts
new file mode 100644
index 0000000..ba9a77b
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/accordion/mod.ts
@@ -0,0 +1,23 @@
+export {
+ //
+ Accordion,
+ AccordionContent,
+ AccordionHeader,
+ AccordionItem,
+ AccordionTrigger,
+ Content,
+ createAccordionScope,
+ Header,
+ Item,
+ //
+ Root,
+ Trigger,
+} from "./Accordion.tsx";
+export type {
+ AccordionContentProps,
+ AccordionHeaderProps,
+ AccordionItemProps,
+ AccordionMultipleProps,
+ AccordionSingleProps,
+ AccordionTriggerProps,
+} from "./Accordion.tsx";
diff --git a/pkg/radix-ui-primitives/preact/alert-dialog/AlertDialog.tsx b/pkg/radix-ui-primitives/preact/alert-dialog/AlertDialog.tsx
new file mode 100644
index 0000000..e85d1b8
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/alert-dialog/AlertDialog.tsx
@@ -0,0 +1,405 @@
+import * as React from "preact/compat";
+import { createContextScope } from "../context/mod.ts";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import * as DialogPrimitive from "../dialog/mod.ts";
+import { createDialogScope } from "../dialog/mod.ts";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { Slottable } from "../slot/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialog
+ * -----------------------------------------------------------------------------------------------*/
+
+const ROOT_NAME = "AlertDialog";
+
+type ScopedProps = P & { __scopeAlertDialog?: Scope };
+const [createAlertDialogContext, createAlertDialogScope] = createContextScope(
+ ROOT_NAME,
+ [
+ createDialogScope,
+ ],
+);
+const useDialogScope = createDialogScope();
+
+type DialogProps = Radix.ComponentPropsWithoutRef;
+interface AlertDialogProps extends Omit {}
+
+const AlertDialog: React.FC = (
+ props: ScopedProps,
+) => {
+ const { __scopeAlertDialog, ...alertDialogProps } = props;
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ return (
+
+ );
+};
+
+AlertDialog.displayName = ROOT_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialogTrigger
+ * -----------------------------------------------------------------------------------------------*/
+const TRIGGER_NAME = "AlertDialogTrigger";
+
+type AlertDialogTriggerElement = React.ElementRef<
+ typeof DialogPrimitive.Trigger
+>;
+type DialogTriggerProps = Radix.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Trigger
+>;
+interface AlertDialogTriggerProps extends DialogTriggerProps {}
+
+const AlertDialogTrigger = React.forwardRef<
+ AlertDialogTriggerElement,
+ AlertDialogTriggerProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAlertDialog, ...triggerProps } = props;
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ return (
+
+ );
+ },
+);
+
+AlertDialogTrigger.displayName = TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialogPortal
+ * -----------------------------------------------------------------------------------------------*/
+
+const PORTAL_NAME = "AlertDialogPortal";
+
+type DialogPortalProps = Radix.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Portal
+>;
+interface AlertDialogPortalProps extends DialogPortalProps {}
+
+const AlertDialogPortal: React.FC = (
+ props: ScopedProps,
+) => {
+ const { __scopeAlertDialog, ...portalProps } = props;
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ return ;
+};
+
+AlertDialogPortal.displayName = PORTAL_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialogOverlay
+ * -----------------------------------------------------------------------------------------------*/
+
+const OVERLAY_NAME = "AlertDialogOverlay";
+
+type AlertDialogOverlayElement = React.ElementRef<
+ typeof DialogPrimitive.Overlay
+>;
+type DialogOverlayProps = Radix.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Overlay
+>;
+interface AlertDialogOverlayProps extends DialogOverlayProps {}
+
+const AlertDialogOverlay = React.forwardRef<
+ AlertDialogOverlayElement,
+ AlertDialogOverlayProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAlertDialog, ...overlayProps } = props;
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ return (
+
+ );
+ },
+);
+
+AlertDialogOverlay.displayName = OVERLAY_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialogContent
+ * -----------------------------------------------------------------------------------------------*/
+
+const CONTENT_NAME = "AlertDialogContent";
+
+type AlertDialogContentContextValue = {
+ cancelRef: React.MutableRefObject;
+};
+
+const [AlertDialogContentProvider, useAlertDialogContentContext] =
+ createAlertDialogContext(CONTENT_NAME);
+
+type AlertDialogContentElement = React.ElementRef<
+ typeof DialogPrimitive.Content
+>;
+type DialogContentProps = Radix.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Content
+>;
+interface AlertDialogContentProps
+ extends
+ Omit {}
+
+const AlertDialogContent = React.forwardRef<
+ AlertDialogContentElement,
+ AlertDialogContentProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAlertDialog, children, ...contentProps } = props;
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ const contentRef = React.useRef(null);
+ const composedRefs = useComposedRefs(forwardedRef, contentRef);
+ const cancelRef = React.useRef(null);
+
+ return (
+
+
+ {
+ event.preventDefault();
+ cancelRef.current?.focus({ preventScroll: true });
+ },
+ )}
+ onPointerDownOutside={(event) => event.preventDefault()}
+ onInteractOutside={(event) => event.preventDefault()}
+ >
+ {
+ /**
+ * We have to use `Slottable` here as we cannot wrap the `AlertDialogContentProvider`
+ * around everything, otherwise the `DescriptionWarning` would be rendered straight away.
+ * This is because we want the accessibility checks to run only once the content is actually
+ * open and that behaviour is already encapsulated in `DialogContent`.
+ */
+ }
+ {children}
+ {process.env.NODE_ENV === "development" && (
+
+ )}
+
+
+
+ );
+ },
+);
+
+AlertDialogContent.displayName = CONTENT_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialogTitle
+ * -----------------------------------------------------------------------------------------------*/
+
+const TITLE_NAME = "AlertDialogTitle";
+
+type AlertDialogTitleElement = React.ElementRef;
+type DialogTitleProps = Radix.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Title
+>;
+interface AlertDialogTitleProps extends DialogTitleProps {}
+
+const AlertDialogTitle = React.forwardRef<
+ AlertDialogTitleElement,
+ AlertDialogTitleProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAlertDialog, ...titleProps } = props;
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ return (
+
+ );
+ },
+);
+
+AlertDialogTitle.displayName = TITLE_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialogDescription
+ * -----------------------------------------------------------------------------------------------*/
+
+const DESCRIPTION_NAME = "AlertDialogDescription";
+
+type AlertDialogDescriptionElement = React.ElementRef<
+ typeof DialogPrimitive.Description
+>;
+type DialogDescriptionProps = Radix.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Description
+>;
+interface AlertDialogDescriptionProps extends DialogDescriptionProps {}
+
+const AlertDialogDescription = React.forwardRef<
+ AlertDialogDescriptionElement,
+ AlertDialogDescriptionProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeAlertDialog, ...descriptionProps } = props;
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ return (
+
+ );
+});
+
+AlertDialogDescription.displayName = DESCRIPTION_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialogAction
+ * -----------------------------------------------------------------------------------------------*/
+
+const ACTION_NAME = "AlertDialogAction";
+
+type AlertDialogActionElement = React.ElementRef;
+type DialogCloseProps = Radix.ComponentPropsWithoutRef<
+ typeof DialogPrimitive.Close
+>;
+interface AlertDialogActionProps extends DialogCloseProps {}
+
+const AlertDialogAction = React.forwardRef<
+ AlertDialogActionElement,
+ AlertDialogActionProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAlertDialog, ...actionProps } = props;
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ return (
+
+ );
+ },
+);
+
+AlertDialogAction.displayName = ACTION_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AlertDialogCancel
+ * -----------------------------------------------------------------------------------------------*/
+
+const CANCEL_NAME = "AlertDialogCancel";
+
+type AlertDialogCancelElement = React.ElementRef;
+interface AlertDialogCancelProps extends DialogCloseProps {}
+
+const AlertDialogCancel = React.forwardRef<
+ AlertDialogCancelElement,
+ AlertDialogCancelProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAlertDialog, ...cancelProps } = props;
+ const { cancelRef } = useAlertDialogContentContext(
+ CANCEL_NAME,
+ __scopeAlertDialog,
+ );
+ const dialogScope = useDialogScope(__scopeAlertDialog);
+ const ref = useComposedRefs(forwardedRef, cancelRef);
+ return (
+
+ );
+ },
+);
+
+AlertDialogCancel.displayName = CANCEL_NAME;
+
+/* ---------------------------------------------------------------------------------------------- */
+
+type DescriptionWarningProps = {
+ contentRef: React.RefObject;
+};
+
+const DescriptionWarning: React.FC = (
+ { contentRef },
+) => {
+ const MESSAGE =
+ `\`${CONTENT_NAME}\` requires a description for the component to be accessible for screen reader users.
+
+You can add a description to the \`${CONTENT_NAME}\` by passing a \`${DESCRIPTION_NAME}\` component as a child, which also benefits sighted users by adding visible context to the dialog.
+
+Alternatively, you can use your own component as a description by assigning it an \`id\` and passing the same value to the \`aria-describedby\` prop in \`${CONTENT_NAME}\`. If the description is confusing or duplicative for sighted users, you can use the \`@radix-ui/react-visually-hidden\` primitive as a wrapper around your description component.
+
+For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`;
+
+ React.useEffect(() => {
+ const hasDescription = document.getElementById(
+ contentRef.current?.getAttribute("aria-describedby")!,
+ );
+ if (!hasDescription) console.warn(MESSAGE);
+ }, [MESSAGE, contentRef]);
+
+ return null;
+};
+
+const Root = AlertDialog;
+const Trigger = AlertDialogTrigger;
+const Portal = AlertDialogPortal;
+const Overlay = AlertDialogOverlay;
+const Content = AlertDialogContent;
+const Action = AlertDialogAction;
+const Cancel = AlertDialogCancel;
+const Title = AlertDialogTitle;
+const Description = AlertDialogDescription;
+
+export {
+ Action,
+ //
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+ Cancel,
+ Content,
+ createAlertDialogScope,
+ Description,
+ Overlay,
+ Portal,
+ //
+ Root,
+ Title,
+ Trigger,
+};
+export type {
+ AlertDialogActionProps,
+ AlertDialogCancelProps,
+ AlertDialogContentProps,
+ AlertDialogDescriptionProps,
+ AlertDialogOverlayProps,
+ AlertDialogPortalProps,
+ AlertDialogProps,
+ AlertDialogTitleProps,
+ AlertDialogTriggerProps,
+};
diff --git a/pkg/radix-ui-primitives/preact/alert-dialog/mod.ts b/pkg/radix-ui-primitives/preact/alert-dialog/mod.ts
new file mode 100644
index 0000000..cd92efc
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/alert-dialog/mod.ts
@@ -0,0 +1,34 @@
+export {
+ Action,
+ //
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+ Cancel,
+ Content,
+ createAlertDialogScope,
+ Description,
+ Overlay,
+ Portal,
+ //
+ Root,
+ Title,
+ Trigger,
+} from "./AlertDialog.tsx";
+export type {
+ AlertDialogActionProps,
+ AlertDialogCancelProps,
+ AlertDialogContentProps,
+ AlertDialogDescriptionProps,
+ AlertDialogOverlayProps,
+ AlertDialogPortalProps,
+ AlertDialogProps,
+ AlertDialogTitleProps,
+ AlertDialogTriggerProps,
+} from "./AlertDialog.tsx";
diff --git a/pkg/radix-ui-primitives/preact/announce/Announce.tsx b/pkg/radix-ui-primitives/preact/announce/Announce.tsx
new file mode 100644
index 0000000..71fe023
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/announce/Announce.tsx
@@ -0,0 +1,265 @@
+import * as React from "preact/compat";
+import * as ReactDOM from "preact/compat";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+import { useLayoutEffect } from "../use-layout-effect/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+
+type RegionType = "polite" | "assertive" | "off";
+type RegionRole = "status" | "alert" | "log" | "none";
+
+const ROLES: { [key in RegionType]: RegionRole } = {
+ polite: "status",
+ assertive: "alert",
+ off: "none",
+};
+
+const listenerMap = new Map();
+
+/* -------------------------------------------------------------------------------------------------
+ * Announce
+ * -----------------------------------------------------------------------------------------------*/
+
+const NAME = "Announce";
+
+type AnnounceElement = React.ElementRef;
+type PrimitiveDivProps = Radix.ComponentPropsWithoutRef;
+interface AnnounceProps extends PrimitiveDivProps {
+ /**
+ * Mirrors the `aria-atomic` DOM attribute for live regions. It is an optional attribute that
+ * indicates whether assistive technologies will present all, or only parts of, the changed region
+ * based on the change notifications defined by the `aria-relevant` attribute.
+ *
+ * @see WAI-ARIA https://www.w3.org/TR/wai-aria-1.2/#aria-atomic
+ * @see Demo http://pauljadam.com/demos/aria-atomic-relevant.html
+ */
+ "aria-atomic"?: boolean;
+ /**
+ * Mirrors the `aria-relevant` DOM attribute for live regions. It is an optional attribute used to
+ * describe what types of changes have occurred to the region, and which changes are relevant and
+ * should be announced. Any change that is not relevant acts in the same manner it would if the
+ * `aria-live` attribute were set to off.
+ *
+ * Unfortunately, `aria-relevant` doesn't behave as expected across all device/screen reader
+ * combinations. It's important to test its implementation before relying on it to work for your
+ * users. The attribute is omitted by default.
+ *
+ * @see WAI-ARIA https://www.w3.org/TR/wai-aria-1.2/#aria-relevant
+ * @see MDN https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-relevant_attribute
+ * @see Opinion https://medium.com/dev-channel/why-authors-should-avoid-aria-relevant-5d3164fab1e3
+ * @see Demo http://pauljadam.com/demos/aria-atomic-relevant.html
+ */
+ "aria-relevant"?: PrimitiveDivProps["aria-relevant"];
+ /**
+ * React children of your component. Children can be mirrored directly or modified to optimize for
+ * screen reader user experience.
+ */
+ children: React.ComponentChildren;
+ /**
+ * An optional unique identifier for the live region.
+ *
+ * By default, `Announce` components create, at most, two unique `aria-live` regions in the
+ * document (one for all `polite` notifications, one for all `assertive` notifications). In some
+ * cases you may wish to append additional `aria-live` regions for distinct purposes (for example,
+ * simple status updates may need to be separated from a stack of toast-style notifications). By
+ * passing an id, you indicate that any content rendered by components with the same identifier
+ * should be mirrored in a separate `aria-live` region.
+ */
+ regionIdentifier?: string;
+ /**
+ * Mirrors the `role` DOM attribute. This is optional and may be useful as an override in some
+ * cases. By default, the role is determined by the `type` prop.
+ *
+ * @see MDN https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions#Preferring_specialized_live_region_roles
+ */
+ role?: RegionRole;
+ /**
+ * Mirrors the `aria-live` DOM attribute. The `aria-live=POLITENESS_SETTING` is used to set the
+ * priority with which screen reader should treat updates to live regions. Its possible settings
+ * are: off, polite or assertive. Defaults to `polite`.
+ *
+ * @see MDN https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
+ */
+ type?: RegionType;
+}
+
+const Announce = React.forwardRef(
+ (props, forwardedRef) => {
+ const {
+ "aria-relevant": ariaRelevant,
+ children,
+ type = "polite",
+ role = ROLES[type],
+ regionIdentifier,
+ ...regionProps
+ } = props;
+
+ const ariaAtomic = ["true", true].includes(
+ regionProps["aria-atomic"] as any,
+ );
+
+ // The region is appended to the root document node, which is usually the global `document` but in
+ // some contexts may be another node. After the Announce element ref is attached, we set the
+ // ownerDocumentRef to make sure we have the right root node. We should only need to do this once.
+ const ownerDocumentRef = React.useRef(document);
+ const setOwnerDocumentFromRef = React.useCallback(
+ (node: HTMLDivElement) => {
+ if (node) {
+ ownerDocumentRef.current = node.ownerDocument;
+ }
+ },
+ [],
+ );
+ const ownRef = React.useRef(null);
+ const ref = useComposedRefs(forwardedRef, ownRef, setOwnerDocumentFromRef);
+
+ const [region, setRegion] = React.useState();
+ const relevant = ariaRelevant
+ ? Array.isArray(ariaRelevant) ? ariaRelevant.join(" ") : ariaRelevant
+ : undefined;
+
+ const getLiveRegionElement = React.useCallback(() => {
+ const ownerDocument = ownerDocumentRef.current;
+ const regionConfig = {
+ type,
+ role,
+ relevant,
+ id: regionIdentifier,
+ atomic: ariaAtomic,
+ };
+ const regionSelector = buildSelector(regionConfig);
+ const element = ownerDocument.querySelector(regionSelector);
+
+ return element || buildLiveRegionElement(ownerDocument, regionConfig);
+ }, [ariaAtomic, relevant, role, type, regionIdentifier]);
+
+ useLayoutEffect(() => {
+ setRegion(getLiveRegionElement() as HTMLElement);
+ }, [getLiveRegionElement]);
+
+ // In some screen reader/browser combinations, alerts coming from an inactive browser tab may be
+ // announced, which is a confusing experience for a user interacting with a completely different
+ // page. When the page visibility changes we'll update the `role` and `aria-live` attributes of
+ // our region element to prevent that.
+ // https://inclusive-components.design/notifications/#restrictingmessagestocontexts
+ React.useEffect(() => {
+ const ownerDocument = ownerDocumentRef.current;
+ function updateAttributesOnVisibilityChange() {
+ regionElement.setAttribute(
+ "role",
+ ownerDocument.hidden ? "none" : role,
+ );
+ regionElement.setAttribute(
+ "aria-live",
+ ownerDocument.hidden ? "off" : type,
+ );
+ }
+
+ // Ok, so this might look a little weird and confusing, but here's what's going on:
+ // - We need to hide `aria-live` regions via a global event listener, as noted in the comment
+ // above.
+ // - We only need one listener per region. Keep in mind that each `Announce` does not
+ // necessarily generate a unique live region element.
+ // - We track whether or not a listener has already been attached for a given region in a map
+ // so we can skip these effects after `Announce` is used again with a shared live region.
+ const regionElement = getLiveRegionElement();
+
+ if (!listenerMap.get(regionElement)) {
+ ownerDocument.addEventListener(
+ "visibilitychange",
+ updateAttributesOnVisibilityChange,
+ );
+ listenerMap.set(regionElement, 1);
+ } else {
+ const announceCount = listenerMap.get(regionElement)!;
+ listenerMap.set(regionElement, announceCount + 1);
+ }
+
+ return function () {
+ const announceCount = listenerMap.get(regionElement)!;
+ listenerMap.set(regionElement, announceCount - 1);
+ if (announceCount === 1) {
+ ownerDocument.removeEventListener(
+ "visibilitychange",
+ updateAttributesOnVisibilityChange,
+ );
+ }
+ };
+ }, [getLiveRegionElement, role, type]);
+
+ return (
+
+
+ {children}
+
+
+ {/* portal into live region for screen reader announcements */}
+ {region && ReactDOM.createPortal({children}
, region)}
+
+ );
+ },
+);
+
+Announce.displayName = NAME;
+
+/* ---------------------------------------------------------------------------------------------- */
+
+type LiveRegionOptions = {
+ type: string;
+ relevant?: string;
+ role: string;
+ atomic?: boolean;
+ id?: string;
+};
+
+function buildLiveRegionElement(
+ ownerDocument: Document,
+ { type, relevant, role, atomic, id }: LiveRegionOptions,
+) {
+ const element = ownerDocument.createElement("div");
+ element.setAttribute(getLiveRegionPartDataAttr(id), "");
+ element.setAttribute(
+ "style",
+ "position: absolute; top: -1px; width: 1px; height: 1px; overflow: hidden;",
+ );
+ ownerDocument.body.appendChild(element);
+
+ element.setAttribute("aria-live", type);
+ element.setAttribute("aria-atomic", String(atomic || false));
+ element.setAttribute("role", role);
+ if (relevant) {
+ element.setAttribute("aria-relevant", relevant);
+ }
+
+ return element;
+}
+
+function buildSelector(
+ { type, relevant, role, atomic, id }: LiveRegionOptions,
+) {
+ return `[${getLiveRegionPartDataAttr(id)}]${
+ [
+ ["aria-live", type],
+ ["aria-atomic", atomic],
+ ["aria-relevant", relevant],
+ ["role", role],
+ ]
+ .filter(([, val]) => !!val)
+ .map(([attr, val]) => `[${attr}=${val}]`)
+ .join("")
+ }`;
+}
+
+function getLiveRegionPartDataAttr(id?: string) {
+ return "data-radix-announce-region" + (id ? `-${id}` : "");
+}
+
+const Root = Announce;
+
+export {
+ Announce,
+ //
+ Root,
+};
+export type { AnnounceProps };
diff --git a/pkg/radix-ui-primitives/preact/announce/mod.ts b/pkg/radix-ui-primitives/preact/announce/mod.ts
new file mode 100644
index 0000000..df6fbd0
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/announce/mod.ts
@@ -0,0 +1,6 @@
+export {
+ Announce,
+ //
+ Root,
+} from "./Announce.tsx";
+export type { AnnounceProps } from "./Announce.tsx";
diff --git a/pkg/radix-ui-primitives/preact/arrow/Arrow.tsx b/pkg/radix-ui-primitives/preact/arrow/Arrow.tsx
new file mode 100644
index 0000000..d9fec43
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/arrow/Arrow.tsx
@@ -0,0 +1,46 @@
+import * as React from "preact/compat";
+import { Primitive } from "../primitive/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+
+/* -------------------------------------------------------------------------------------------------
+ * Arrow
+ * -----------------------------------------------------------------------------------------------*/
+
+const NAME = "Arrow";
+
+type ArrowElement = React.ElementRef;
+type PrimitiveSvgProps = Radix.ComponentPropsWithoutRef;
+interface ArrowProps extends PrimitiveSvgProps {}
+
+const Arrow = React.forwardRef(
+ (props, forwardedRef) => {
+ const { children, width = 10, height = 5, ...arrowProps } = props;
+ return (
+
+ {/* We use their children if they're slotting to replace the whole svg */}
+ {props.asChild ? children : }
+
+ );
+ },
+);
+
+Arrow.displayName = NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+const Root = Arrow;
+
+export {
+ Arrow,
+ //
+ Root,
+};
+export type { ArrowProps };
diff --git a/pkg/radix-ui-primitives/preact/arrow/mod.ts b/pkg/radix-ui-primitives/preact/arrow/mod.ts
new file mode 100644
index 0000000..66c3c5c
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/arrow/mod.ts
@@ -0,0 +1,6 @@
+export {
+ Arrow,
+ //
+ Root,
+} from "./Arrow.tsx";
+export type { ArrowProps } from "./Arrow.tsx";
diff --git a/pkg/radix-ui-primitives/preact/aspect-ratio/AspectRatio.tsx b/pkg/radix-ui-primitives/preact/aspect-ratio/AspectRatio.tsx
new file mode 100644
index 0000000..d9bfd0a
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/aspect-ratio/AspectRatio.tsx
@@ -0,0 +1,64 @@
+import * as React from "preact/compat";
+import { Primitive } from "../primitive/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+
+/* -------------------------------------------------------------------------------------------------
+ * AspectRatio
+ * -----------------------------------------------------------------------------------------------*/
+
+const NAME = "AspectRatio";
+
+type AspectRatioElement = React.ElementRef;
+type PrimitiveDivProps = Radix.ComponentPropsWithoutRef;
+interface AspectRatioProps extends PrimitiveDivProps {
+ ratio?: number;
+}
+
+const AspectRatio = React.forwardRef<
+ AspectRatioElement,
+ AspectRatioProps
+>(
+ (props, forwardedRef) => {
+ const { ratio = 1 / 1, style, ...aspectRatioProps } = props;
+ return (
+
+ );
+ },
+);
+
+AspectRatio.displayName = NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+const Root = AspectRatio;
+
+export {
+ AspectRatio,
+ //
+ Root,
+};
+export type { AspectRatioProps };
diff --git a/pkg/radix-ui-primitives/preact/aspect-ratio/mod.ts b/pkg/radix-ui-primitives/preact/aspect-ratio/mod.ts
new file mode 100644
index 0000000..b5eefce
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/aspect-ratio/mod.ts
@@ -0,0 +1,6 @@
+export {
+ AspectRatio,
+ //
+ Root,
+} from "./AspectRatio.tsx";
+export type { AspectRatioProps } from "./AspectRatio.tsx";
diff --git a/pkg/radix-ui-primitives/preact/avatar/Avatar.tsx b/pkg/radix-ui-primitives/preact/avatar/Avatar.tsx
new file mode 100644
index 0000000..12e8c30
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/avatar/Avatar.tsx
@@ -0,0 +1,189 @@
+import * as React from "preact/compat";
+import { createContextScope } from "../context/mod.ts";
+import { useCallbackRef } from "../use-callback-ref/mod.ts";
+import { useLayoutEffect } from "../use-layout-effect/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+
+/* -------------------------------------------------------------------------------------------------
+ * Avatar
+ * -----------------------------------------------------------------------------------------------*/
+
+const AVATAR_NAME = "Avatar";
+
+type ScopedProps = P & { __scopeAvatar?: Scope };
+const [createAvatarContext, createAvatarScope] = createContextScope(
+ AVATAR_NAME,
+);
+
+type ImageLoadingStatus = "idle" | "loading" | "loaded" | "error";
+
+type AvatarContextValue = {
+ imageLoadingStatus: ImageLoadingStatus;
+ onImageLoadingStatusChange(status: ImageLoadingStatus): void;
+};
+
+const [AvatarProvider, useAvatarContext] = createAvatarContext<
+ AvatarContextValue
+>(AVATAR_NAME);
+
+type AvatarElement = React.ElementRef;
+type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef;
+interface AvatarProps extends PrimitiveSpanProps {}
+
+const Avatar = React.forwardRef(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAvatar, ...avatarProps } = props;
+ const [imageLoadingStatus, setImageLoadingStatus] = React.useState<
+ ImageLoadingStatus
+ >("idle");
+ return (
+
+
+
+ );
+ },
+);
+
+Avatar.displayName = AVATAR_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AvatarImage
+ * -----------------------------------------------------------------------------------------------*/
+
+const IMAGE_NAME = "AvatarImage";
+
+type AvatarImageElement = React.ElementRef;
+type PrimitiveImageProps = Radix.ComponentPropsWithoutRef;
+interface AvatarImageProps extends PrimitiveImageProps {
+ onLoadingStatusChange?: (status: ImageLoadingStatus) => void;
+}
+
+const AvatarImage = React.forwardRef<
+ AvatarImageElement,
+ AvatarImageProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const {
+ __scopeAvatar,
+ src,
+ onLoadingStatusChange = () => {},
+ ...imageProps
+ } = props;
+ const context = useAvatarContext(IMAGE_NAME, __scopeAvatar);
+ const imageLoadingStatus = useImageLoadingStatus(src);
+ const handleLoadingStatusChange = useCallbackRef(
+ (status: ImageLoadingStatus) => {
+ onLoadingStatusChange(status);
+ context.onImageLoadingStatusChange(status);
+ },
+ );
+
+ useLayoutEffect(() => {
+ if (imageLoadingStatus !== "idle") {
+ handleLoadingStatusChange(imageLoadingStatus);
+ }
+ }, [imageLoadingStatus, handleLoadingStatusChange]);
+
+ return imageLoadingStatus === "loaded"
+ ?
+ : null;
+ },
+);
+
+AvatarImage.displayName = IMAGE_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * AvatarFallback
+ * -----------------------------------------------------------------------------------------------*/
+
+const FALLBACK_NAME = "AvatarFallback";
+
+type AvatarFallbackElement = React.ElementRef;
+interface AvatarFallbackProps extends PrimitiveSpanProps {
+ delayMs?: number;
+}
+
+const AvatarFallback = React.forwardRef<
+ AvatarFallbackElement,
+ AvatarFallbackProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeAvatar, delayMs, ...fallbackProps } = props;
+ const context = useAvatarContext(FALLBACK_NAME, __scopeAvatar);
+ const [canRender, setCanRender] = React.useState(
+ delayMs === undefined,
+ );
+
+ React.useEffect(() => {
+ if (delayMs !== undefined) {
+ const timerId = window.setTimeout(() => setCanRender(true), delayMs);
+ return () => window.clearTimeout(timerId);
+ }
+ }, [delayMs]);
+
+ return canRender && context.imageLoadingStatus !== "loaded"
+ ?
+ : null;
+ },
+);
+
+AvatarFallback.displayName = FALLBACK_NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+function useImageLoadingStatus(src?: string) {
+ const [loadingStatus, setLoadingStatus] = React.useState<
+ ImageLoadingStatus
+ >(
+ "idle",
+ );
+
+ useLayoutEffect(() => {
+ if (!src) {
+ setLoadingStatus("error");
+ return;
+ }
+
+ let isMounted = true;
+ const image = new window.Image();
+
+ const updateStatus = (status: ImageLoadingStatus) => () => {
+ if (!isMounted) return;
+ setLoadingStatus(status);
+ };
+
+ setLoadingStatus("loading");
+ image.onload = updateStatus("loaded");
+ image.onerror = updateStatus("error");
+ image.src = src;
+
+ return () => {
+ isMounted = false;
+ };
+ }, [src]);
+
+ return loadingStatus;
+}
+const Root = Avatar;
+const Image = AvatarImage;
+const Fallback = AvatarFallback;
+
+export {
+ //
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+ createAvatarScope,
+ Fallback,
+ Image,
+ //
+ Root,
+};
+export type { AvatarFallbackProps, AvatarImageProps, AvatarProps };
diff --git a/pkg/radix-ui-primitives/preact/avatar/mod.ts b/pkg/radix-ui-primitives/preact/avatar/mod.ts
new file mode 100644
index 0000000..be7fc4f
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/avatar/mod.ts
@@ -0,0 +1,16 @@
+export {
+ //
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+ createAvatarScope,
+ Fallback,
+ Image,
+ //
+ Root,
+} from "./Avatar.tsx";
+export type {
+ AvatarFallbackProps,
+ AvatarImageProps,
+ AvatarProps,
+} from "./Avatar.tsx";
diff --git a/pkg/radix-ui-primitives/preact/checkbox/Checkbox.tsx b/pkg/radix-ui-primitives/preact/checkbox/Checkbox.tsx
new file mode 100644
index 0000000..3643555
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/checkbox/Checkbox.tsx
@@ -0,0 +1,264 @@
+import * as React from "preact/compat";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { createContextScope } from "../context/mod.ts";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { useControllableState } from "../use-controllable-state/mod.ts";
+import { usePrevious } from "../use-previous/mod.ts";
+import { useSize } from "../use-size/mod.ts";
+import { Presence } from "../presence/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+
+/* -------------------------------------------------------------------------------------------------
+ * Checkbox
+ * -----------------------------------------------------------------------------------------------*/
+
+const CHECKBOX_NAME = "Checkbox";
+
+type ScopedProps = P & { __scopeCheckbox?: Scope };
+const [createCheckboxContext, createCheckboxScope] = createContextScope(
+ CHECKBOX_NAME,
+);
+
+type CheckedState = boolean | "indeterminate";
+
+type CheckboxContextValue = {
+ state: CheckedState;
+ disabled?: boolean;
+};
+
+const [CheckboxProvider, useCheckboxContext] = createCheckboxContext<
+ CheckboxContextValue
+>(CHECKBOX_NAME);
+
+type CheckboxElement = React.ElementRef;
+type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef<
+ typeof Primitive.button
+>;
+interface CheckboxProps
+ extends Omit {
+ checked?: CheckedState;
+ defaultChecked?: CheckedState;
+ required?: boolean;
+ onCheckedChange?(checked: CheckedState): void;
+}
+
+const Checkbox = React.forwardRef(
+ (props: ScopedProps, forwardedRef) => {
+ const {
+ __scopeCheckbox,
+ name,
+ checked: checkedProp,
+ defaultChecked,
+ required,
+ disabled,
+ value = "on",
+ onCheckedChange,
+ ...checkboxProps
+ } = props;
+ const [button, setButton] = React.useState(
+ null,
+ );
+ const composedRefs = useComposedRefs(
+ forwardedRef,
+ (node) => setButton(node),
+ );
+ const hasConsumerStoppedPropagationRef = React.useRef(false);
+ // We set this to true by default so that events bubble to forms without JS (SSR)
+ const isFormControl = button ? Boolean(button.closest("form")) : true;
+ const [checked = false, setChecked] = useControllableState({
+ prop: checkedProp,
+ defaultProp: defaultChecked,
+ onChange: onCheckedChange,
+ });
+ const initialCheckedStateRef = React.useRef(checked);
+ React.useEffect(() => {
+ const form = button?.form;
+ if (form) {
+ const reset = () => setChecked(initialCheckedStateRef.current);
+ form.addEventListener("reset", reset);
+ return () => form.removeEventListener("reset", reset);
+ }
+ }, [button, setChecked]);
+
+ return (
+
+ {
+ // According to WAI ARIA, Checkboxes don't activate on enter keypress
+ if (event.key === "Enter") event.preventDefault();
+ })}
+ onClick={composeEventHandlers(props.onClick, (event) => {
+ setChecked((
+ prevChecked,
+ ) => (isIndeterminate(prevChecked) ? true : !prevChecked));
+ if (isFormControl) {
+ hasConsumerStoppedPropagationRef.current = event
+ .isPropagationStopped();
+ // if checkbox is in a form, stop propagation from the button so that we only propagate
+ // one click event (from the input). We propagate changes from an input so that native
+ // form validation works and form events reflect checkbox updates.
+ if (!hasConsumerStoppedPropagationRef.current) {
+ event.stopPropagation();
+ }
+ }
+ })}
+ />
+ {isFormControl && (
+
+ )}
+
+ );
+ },
+);
+
+Checkbox.displayName = CHECKBOX_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * CheckboxIndicator
+ * -----------------------------------------------------------------------------------------------*/
+
+const INDICATOR_NAME = "CheckboxIndicator";
+
+type CheckboxIndicatorElement = React.ElementRef;
+type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef;
+interface CheckboxIndicatorProps extends PrimitiveSpanProps {
+ /**
+ * Used to force mounting when more control is needed. Useful when
+ * controlling animation with React animation libraries.
+ */
+ forceMount?: true;
+}
+
+const CheckboxIndicator = React.forwardRef<
+ CheckboxIndicatorElement,
+ CheckboxIndicatorProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeCheckbox, forceMount, ...indicatorProps } = props;
+ const context = useCheckboxContext(INDICATOR_NAME, __scopeCheckbox);
+ return (
+
+
+
+ );
+ },
+);
+
+CheckboxIndicator.displayName = INDICATOR_NAME;
+
+/* ---------------------------------------------------------------------------------------------- */
+
+type InputProps = Radix.ComponentPropsWithoutRef<"input">;
+interface BubbleInputProps extends Omit {
+ checked: CheckedState;
+ control: HTMLElement | null;
+ bubbles: boolean;
+}
+
+const BubbleInput = (props: BubbleInputProps) => {
+ const { control, checked, bubbles = true, ...inputProps } = props;
+ const ref = React.useRef(null);
+ const prevChecked = usePrevious(checked);
+ const controlSize = useSize(control);
+
+ // Bubble checked change to parents (e.g form change event)
+ React.useEffect(() => {
+ const input = ref.current!;
+ const inputProto = window.HTMLInputElement.prototype;
+ const descriptor = Object.getOwnPropertyDescriptor(
+ inputProto,
+ "checked",
+ ) as PropertyDescriptor;
+ const setChecked = descriptor.set;
+
+ if (prevChecked !== checked && setChecked) {
+ const event = new Event("click", { bubbles });
+ input.indeterminate = isIndeterminate(checked);
+ setChecked.call(input, isIndeterminate(checked) ? false : checked);
+ input.dispatchEvent(event);
+ }
+ }, [prevChecked, checked, bubbles]);
+
+ return (
+
+ );
+};
+
+function isIndeterminate(checked?: CheckedState): checked is "indeterminate" {
+ return checked === "indeterminate";
+}
+
+function getState(checked: CheckedState) {
+ return isIndeterminate(checked)
+ ? "indeterminate"
+ : checked
+ ? "checked"
+ : "unchecked";
+}
+
+const Root = Checkbox;
+const Indicator = CheckboxIndicator;
+
+export {
+ //
+ Checkbox,
+ CheckboxIndicator,
+ createCheckboxScope,
+ Indicator,
+ //
+ Root,
+};
+export type { CheckboxIndicatorProps, CheckboxProps };
diff --git a/pkg/radix-ui-primitives/preact/checkbox/mod.ts b/pkg/radix-ui-primitives/preact/checkbox/mod.ts
new file mode 100644
index 0000000..d64ce0b
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/checkbox/mod.ts
@@ -0,0 +1,10 @@
+export {
+ //
+ Checkbox,
+ CheckboxIndicator,
+ createCheckboxScope,
+ Indicator,
+ //
+ Root,
+} from "./Checkbox.tsx";
+export type { CheckboxIndicatorProps, CheckboxProps } from "./Checkbox.tsx";
diff --git a/pkg/radix-ui-primitives/preact/collapsible/Collapsible.tsx b/pkg/radix-ui-primitives/preact/collapsible/Collapsible.tsx
new file mode 100644
index 0000000..3d4ccf7
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/collapsible/Collapsible.tsx
@@ -0,0 +1,282 @@
+import * as React from "preact/compat";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { createContextScope } from "../context/mod.ts";
+import { useControllableState } from "../use-controllable-state/mod.ts";
+import { useLayoutEffect } from "../use-layout-effect/mod.ts";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+import { Presence } from "../presence/mod.ts";
+import { useId } from "../id/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+
+/* -------------------------------------------------------------------------------------------------
+ * Collapsible
+ * -----------------------------------------------------------------------------------------------*/
+
+const COLLAPSIBLE_NAME = "Collapsible";
+
+type ScopedProps = P & { __scopeCollapsible?: Scope };
+const [createCollapsibleContext, createCollapsibleScope] = createContextScope(
+ COLLAPSIBLE_NAME,
+);
+
+type CollapsibleContextValue = {
+ contentId: string;
+ disabled?: boolean;
+ open: boolean;
+ onOpenToggle(): void;
+};
+
+const [CollapsibleProvider, useCollapsibleContext] = createCollapsibleContext<
+ CollapsibleContextValue
+>(COLLAPSIBLE_NAME);
+
+type CollapsibleElement = React.ElementRef;
+type PrimitiveDivProps = Radix.ComponentPropsWithoutRef;
+interface CollapsibleProps extends PrimitiveDivProps {
+ defaultOpen?: boolean;
+ open?: boolean;
+ disabled?: boolean;
+ onOpenChange?(open: boolean): void;
+}
+
+const Collapsible = React.forwardRef<
+ CollapsibleElement,
+ CollapsibleProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const {
+ __scopeCollapsible,
+ open: openProp,
+ defaultOpen,
+ disabled,
+ onOpenChange,
+ ...collapsibleProps
+ } = props;
+
+ const [open = false, setOpen] = useControllableState({
+ prop: openProp,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+
+ return (
+ setOpen((prevOpen) => !prevOpen),
+ [setOpen],
+ )}
+ >
+
+
+ );
+ },
+);
+
+Collapsible.displayName = COLLAPSIBLE_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * CollapsibleTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+const TRIGGER_NAME = "CollapsibleTrigger";
+
+type CollapsibleTriggerElement = React.ElementRef;
+type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef<
+ typeof Primitive.button
+>;
+interface CollapsibleTriggerProps extends PrimitiveButtonProps {}
+
+const CollapsibleTrigger = React.forwardRef<
+ CollapsibleTriggerElement,
+ CollapsibleTriggerProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeCollapsible, ...triggerProps } = props;
+ const context = useCollapsibleContext(TRIGGER_NAME, __scopeCollapsible);
+ return (
+
+ );
+ },
+);
+
+CollapsibleTrigger.displayName = TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * CollapsibleContent
+ * -----------------------------------------------------------------------------------------------*/
+
+const CONTENT_NAME = "CollapsibleContent";
+
+type CollapsibleContentElement = CollapsibleContentImplElement;
+interface CollapsibleContentProps
+ extends Omit {
+ /**
+ * Used to force mounting when more control is needed. Useful when
+ * controlling animation with React animation libraries.
+ */
+ forceMount?: true;
+}
+
+const CollapsibleContent = React.forwardRef<
+ CollapsibleContentElement,
+ CollapsibleContentProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { forceMount, ...contentProps } = props;
+ const context = useCollapsibleContext(
+ CONTENT_NAME,
+ props.__scopeCollapsible,
+ );
+ return (
+
+ {({ present }) => (
+
+ )}
+
+ );
+ },
+);
+
+CollapsibleContent.displayName = CONTENT_NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+type CollapsibleContentImplElement = React.ElementRef;
+interface CollapsibleContentImplProps extends PrimitiveDivProps {
+ present: boolean;
+}
+
+const CollapsibleContentImpl = React.forwardRef<
+ CollapsibleContentImplElement,
+ CollapsibleContentImplProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeCollapsible, present, children, ...contentProps } = props;
+ const context = useCollapsibleContext(CONTENT_NAME, __scopeCollapsible);
+ const [isPresent, setIsPresent] = React.useState(present);
+ const ref = React.useRef(null);
+ const composedRefs = useComposedRefs(forwardedRef, ref);
+ const heightRef = React.useRef(0);
+ const height = heightRef.current;
+ const widthRef = React.useRef(0);
+ const width = widthRef.current;
+ // when opening we want it to immediately open to retrieve dimensions
+ // when closing we delay `present` to retrieve dimensions before closing
+ const isOpen = context.open || isPresent;
+ const isMountAnimationPreventedRef = React.useRef(isOpen);
+ const originalStylesRef = React.useRef>();
+
+ React.useEffect(() => {
+ const rAF = requestAnimationFrame(
+ () => (isMountAnimationPreventedRef.current = false),
+ );
+ return () => cancelAnimationFrame(rAF);
+ }, []);
+
+ useLayoutEffect(() => {
+ const node = ref.current;
+ if (node) {
+ originalStylesRef.current = originalStylesRef.current || {
+ transitionDuration: node.style.transitionDuration,
+ animationName: node.style.animationName,
+ };
+ // block any animations/transitions so the element renders at its full dimensions
+ node.style.transitionDuration = "0s";
+ node.style.animationName = "none";
+
+ // get width and height from full dimensions
+ const rect = node.getBoundingClientRect();
+ heightRef.current = rect.height;
+ widthRef.current = rect.width;
+
+ // kick off any animations/transitions that were originally set up if it isn't the initial mount
+ if (!isMountAnimationPreventedRef.current) {
+ node.style.transitionDuration =
+ originalStylesRef.current.transitionDuration;
+ node.style.animationName = originalStylesRef.current.animationName;
+ }
+
+ setIsPresent(present);
+ }
+ /**
+ * depends on `context.open` because it will change to `false`
+ * when a close is triggered but `present` will be `false` on
+ * animation end (so when close finishes). This allows us to
+ * retrieve the dimensions *before* closing.
+ */
+ }, [context.open, present]);
+
+ return (
+
+ {isOpen && children}
+
+ );
+});
+
+/* -----------------------------------------------------------------------------------------------*/
+
+function getState(open?: boolean) {
+ return open ? "open" : "closed";
+}
+
+const Root = Collapsible;
+const Trigger = CollapsibleTrigger;
+const Content = CollapsibleContent;
+
+export {
+ //
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+ Content,
+ createCollapsibleScope,
+ //
+ Root,
+ Trigger,
+};
+export type {
+ CollapsibleContentProps,
+ CollapsibleProps,
+ CollapsibleTriggerProps,
+};
diff --git a/pkg/radix-ui-primitives/preact/collapsible/mod.ts b/pkg/radix-ui-primitives/preact/collapsible/mod.ts
new file mode 100644
index 0000000..cd5b616
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/collapsible/mod.ts
@@ -0,0 +1,16 @@
+export {
+ //
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+ Content,
+ createCollapsibleScope,
+ //
+ Root,
+ Trigger,
+} from "./Collapsible.tsx";
+export type {
+ CollapsibleContentProps,
+ CollapsibleProps,
+ CollapsibleTriggerProps,
+} from "./Collapsible.tsx";
diff --git a/pkg/radix-ui-primitives/preact/collection/Collection.tsx b/pkg/radix-ui-primitives/preact/collection/Collection.tsx
new file mode 100644
index 0000000..1c7cf35
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/collection/Collection.tsx
@@ -0,0 +1,159 @@
+import * as React from "preact/compat";
+import { createContextScope } from "../context/mod.ts";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { Slot } from "../slot/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+
+type SlotProps = Radix.ComponentPropsWithoutRef;
+type CollectionElement = HTMLElement;
+interface CollectionProps extends SlotProps {
+ scope: any;
+}
+
+// We have resorted to returning slots directly rather than exposing primitives that can then
+// be slotted like `…`.
+// This is because we encountered issues with generic types that cannot be statically analysed
+// due to creating them dynamically via createCollection.
+
+function createCollection(
+ name: string,
+) {
+ /* -----------------------------------------------------------------------------------------------
+ * CollectionProvider
+ * ---------------------------------------------------------------------------------------------*/
+
+ const PROVIDER_NAME = name + "CollectionProvider";
+ const [createCollectionContext, createCollectionScope] = createContextScope(
+ PROVIDER_NAME,
+ );
+
+ type ContextValue = {
+ collectionRef: React.RefObject;
+ itemMap: Map<
+ React.RefObject,
+ { ref: React.RefObject } & ItemData
+ >;
+ };
+
+ const [CollectionProviderImpl, useCollectionContext] =
+ createCollectionContext(
+ PROVIDER_NAME,
+ { collectionRef: { current: null }, itemMap: new Map() },
+ );
+
+ const CollectionProvider: React.FC<
+ { children?: React.ComponentChildren; scope: any }
+ > = (props) => {
+ const { scope, children } = props;
+ const ref = React.useRef(null);
+ const itemMap =
+ React.useRef(new Map()).current;
+ return (
+
+ {children}
+
+ );
+ };
+
+ CollectionProvider.displayName = PROVIDER_NAME;
+
+ /* -----------------------------------------------------------------------------------------------
+ * CollectionSlot
+ * ---------------------------------------------------------------------------------------------*/
+
+ const COLLECTION_SLOT_NAME = name + "CollectionSlot";
+
+ const CollectionSlot = React.forwardRef<
+ CollectionElement,
+ CollectionProps
+ >(
+ (props, forwardedRef) => {
+ const { scope, children } = props;
+ const context = useCollectionContext(COLLECTION_SLOT_NAME, scope);
+ const composedRefs = useComposedRefs(forwardedRef, context.collectionRef);
+ return {children};
+ },
+ );
+
+ CollectionSlot.displayName = COLLECTION_SLOT_NAME;
+
+ /* -----------------------------------------------------------------------------------------------
+ * CollectionItem
+ * ---------------------------------------------------------------------------------------------*/
+
+ const ITEM_SLOT_NAME = name + "CollectionItemSlot";
+ const ITEM_DATA_ATTR = "data-radix-collection-item";
+
+ type CollectionItemSlotProps = ItemData & {
+ children: React.ComponentChildren;
+ scope: any;
+ };
+
+ const CollectionItemSlot = React.forwardRef<
+ ItemElement,
+ CollectionItemSlotProps
+ >(
+ (props, forwardedRef) => {
+ const { scope, children, ...itemData } = props;
+ const ref = React.useRef(null);
+ const composedRefs = useComposedRefs(forwardedRef, ref);
+ const context = useCollectionContext(ITEM_SLOT_NAME, scope);
+
+ React.useEffect(() => {
+ context.itemMap.set(ref, { ref, ...(itemData as unknown as ItemData) });
+ return () => void context.itemMap.delete(ref);
+ });
+
+ return (
+
+ {children}
+
+ );
+ },
+ );
+
+ CollectionItemSlot.displayName = ITEM_SLOT_NAME;
+
+ /* -----------------------------------------------------------------------------------------------
+ * useCollection
+ * ---------------------------------------------------------------------------------------------*/
+
+ function useCollection(scope: any) {
+ const context = useCollectionContext(name + "CollectionConsumer", scope);
+
+ const getItems = React.useCallback(() => {
+ const collectionNode = context.collectionRef.current;
+ if (!collectionNode) return [];
+ const orderedNodes = Array.from(
+ collectionNode.querySelectorAll(`[${ITEM_DATA_ATTR}]`),
+ );
+ const items = Array.from(context.itemMap.values());
+ const orderedItems = items.sort(
+ (a, b) =>
+ orderedNodes.indexOf(a.ref.current!) -
+ orderedNodes.indexOf(b.ref.current!),
+ );
+ return orderedItems;
+ }, [context.collectionRef, context.itemMap]);
+
+ return getItems;
+ }
+
+ return [
+ {
+ Provider: CollectionProvider,
+ Slot: CollectionSlot,
+ ItemSlot: CollectionItemSlot,
+ },
+ useCollection,
+ createCollectionScope,
+ ] as const;
+}
+
+export { createCollection };
+export type { CollectionProps };
diff --git a/pkg/radix-ui-primitives/preact/collection/mod.ts b/pkg/radix-ui-primitives/preact/collection/mod.ts
new file mode 100644
index 0000000..ad1c7b7
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/collection/mod.ts
@@ -0,0 +1,2 @@
+export { createCollection } from "./Collection.tsx";
+export type { CollectionProps } from "./Collection.tsx";
diff --git a/pkg/radix-ui-primitives/preact/compose-refs/composeRefs.tsx b/pkg/radix-ui-primitives/preact/compose-refs/composeRefs.tsx
new file mode 100644
index 0000000..ae99699
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/compose-refs/composeRefs.tsx
@@ -0,0 +1,34 @@
+import * as React from "preact/compat";
+
+type PossibleRef = React.Ref | undefined;
+
+/**
+ * Set a given ref to a given value
+ * This utility takes care of different types of refs: callback refs and RefObject(s)
+ */
+function setRef(ref: PossibleRef, value: T) {
+ if (typeof ref === "function") {
+ ref(value);
+ } else if (ref !== null && ref !== undefined) {
+ (ref as React.MutableRefObject).current = value;
+ }
+}
+
+/**
+ * A utility to compose multiple refs together
+ * Accepts callback refs and RefObject(s)
+ */
+function composeRefs(...refs: PossibleRef[]) {
+ return (node: T) => refs.forEach((ref) => setRef(ref, node));
+}
+
+/**
+ * A custom hook that composes multiple refs
+ * Accepts callback refs and RefObject(s)
+ */
+function useComposedRefs(...refs: PossibleRef[]) {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ return React.useCallback(composeRefs(...refs), refs);
+}
+
+export { composeRefs, useComposedRefs };
diff --git a/pkg/radix-ui-primitives/preact/compose-refs/mod.ts b/pkg/radix-ui-primitives/preact/compose-refs/mod.ts
new file mode 100644
index 0000000..cd0fa29
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/compose-refs/mod.ts
@@ -0,0 +1 @@
+export { composeRefs, useComposedRefs } from "./composeRefs.tsx";
diff --git a/pkg/radix-ui-primitives/preact/context-menu/ContextMenu.tsx b/pkg/radix-ui-primitives/preact/context-menu/ContextMenu.tsx
new file mode 100644
index 0000000..e915a34
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/context-menu/ContextMenu.tsx
@@ -0,0 +1,733 @@
+import * as React from "preact/compat";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { createContextScope } from "../context/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+import * as MenuPrimitive from "../menu/mod.ts";
+import { createMenuScope } from "../menu/mod.ts";
+import { useCallbackRef } from "../use-callback-ref/mod.ts";
+import { useControllableState } from "../use-controllable-state/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+
+type Direction = "ltr" | "rtl";
+type Point = { x: number; y: number };
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenu
+ * -----------------------------------------------------------------------------------------------*/
+
+const CONTEXT_MENU_NAME = "ContextMenu";
+
+type ScopedProps = P & { __scopeContextMenu?: Scope };
+const [createContextMenuContext, createContextMenuScope] = createContextScope(
+ CONTEXT_MENU_NAME,
+ [
+ createMenuScope,
+ ],
+);
+const useMenuScope = createMenuScope();
+
+type ContextMenuContextValue = {
+ open: boolean;
+ onOpenChange(open: boolean): void;
+ modal: boolean;
+};
+
+const [ContextMenuProvider, useContextMenuContext] = createContextMenuContext<
+ ContextMenuContextValue
+>(CONTEXT_MENU_NAME);
+
+interface ContextMenuProps {
+ children?: React.ComponentChildren;
+ onOpenChange?(open: boolean): void;
+ dir?: Direction;
+ modal?: boolean;
+}
+
+const ContextMenu: React.FC = (
+ props: ScopedProps,
+) => {
+ const { __scopeContextMenu, children, onOpenChange, dir, modal = true } =
+ props;
+ const [open, setOpen] = React.useState(false);
+ const menuScope = useMenuScope(__scopeContextMenu);
+ const handleOpenChangeProp = useCallbackRef(onOpenChange);
+
+ const handleOpenChange = React.useCallback(
+ (open: boolean) => {
+ setOpen(open);
+ handleOpenChangeProp(open);
+ },
+ [handleOpenChangeProp],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+ContextMenu.displayName = CONTEXT_MENU_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+const TRIGGER_NAME = "ContextMenuTrigger";
+
+type ContextMenuTriggerElement = React.ElementRef;
+type PrimitiveSpanProps = Radix.ComponentPropsWithoutRef;
+interface ContextMenuTriggerProps extends PrimitiveSpanProps {
+ disabled?: boolean;
+}
+
+const ContextMenuTrigger = React.forwardRef<
+ ContextMenuTriggerElement,
+ ContextMenuTriggerProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, disabled = false, ...triggerProps } = props;
+ const context = useContextMenuContext(TRIGGER_NAME, __scopeContextMenu);
+ const menuScope = useMenuScope(__scopeContextMenu);
+ const pointRef = React.useRef({ x: 0, y: 0 });
+ const virtualRef = React.useRef({
+ getBoundingClientRect: () =>
+ DOMRect.fromRect({ width: 0, height: 0, ...pointRef.current }),
+ });
+ const longPressTimerRef = React.useRef(0);
+ const clearLongPress = React.useCallback(
+ () => window.clearTimeout(longPressTimerRef.current),
+ [],
+ );
+ const handleOpen = (event: React.MouseEvent | React.PointerEvent) => {
+ pointRef.current = { x: event.clientX, y: event.clientY };
+ context.onOpenChange(true);
+ };
+
+ React.useEffect(() => clearLongPress, [clearLongPress]);
+ React.useEffect(() => void (disabled && clearLongPress()), [
+ disabled,
+ clearLongPress,
+ ]);
+
+ return (
+ <>
+
+ {
+ // clearing the long press here because some platforms already support
+ // long press to trigger a `contextmenu` event
+ clearLongPress();
+ handleOpen(event);
+ event.preventDefault();
+ })}
+ onPointerDown={disabled ? props.onPointerDown : composeEventHandlers(
+ props.onPointerDown,
+ whenTouchOrPen((event) => {
+ // clear the long press here in case there's multiple touch points
+ clearLongPress();
+ longPressTimerRef.current = window.setTimeout(
+ () => handleOpen(event),
+ 700,
+ );
+ }),
+ )}
+ onPointerMove={disabled ? props.onPointerMove : composeEventHandlers(
+ props.onPointerMove,
+ whenTouchOrPen(clearLongPress),
+ )}
+ onPointerCancel={disabled
+ ? props.onPointerCancel
+ : composeEventHandlers(
+ props.onPointerCancel,
+ whenTouchOrPen(clearLongPress),
+ )}
+ onPointerUp={disabled ? props.onPointerUp : composeEventHandlers(
+ props.onPointerUp,
+ whenTouchOrPen(clearLongPress),
+ )}
+ />
+ >
+ );
+ },
+);
+
+ContextMenuTrigger.displayName = TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuPortal
+ * -----------------------------------------------------------------------------------------------*/
+
+const PORTAL_NAME = "ContextMenuPortal";
+
+type MenuPortalProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Portal
+>;
+interface ContextMenuPortalProps extends MenuPortalProps {}
+
+const ContextMenuPortal: React.FC = (
+ props: ScopedProps,
+) => {
+ const { __scopeContextMenu, ...portalProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return ;
+};
+
+ContextMenuPortal.displayName = PORTAL_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuContent
+ * -----------------------------------------------------------------------------------------------*/
+
+const CONTENT_NAME = "ContextMenuContent";
+
+type ContextMenuContentElement = React.ElementRef;
+type MenuContentProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Content
+>;
+interface ContextMenuContentProps
+ extends
+ Omit {}
+
+const ContextMenuContent = React.forwardRef<
+ ContextMenuContentElement,
+ ContextMenuContentProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...contentProps } = props;
+ const context = useContextMenuContext(CONTENT_NAME, __scopeContextMenu);
+ const menuScope = useMenuScope(__scopeContextMenu);
+ const hasInteractedOutsideRef = React.useRef(false);
+
+ return (
+ {
+ props.onCloseAutoFocus?.(event);
+
+ if (!event.defaultPrevented && hasInteractedOutsideRef.current) {
+ event.preventDefault();
+ }
+
+ hasInteractedOutsideRef.current = false;
+ }}
+ onInteractOutside={(event) => {
+ props.onInteractOutside?.(event);
+
+ if (
+ !event.defaultPrevented && !context.modal
+ ) hasInteractedOutsideRef.current = true;
+ }}
+ style={{
+ ...props.style,
+ // re-namespace exposed content custom properties
+ ...{
+ "--radix-context-menu-content-transform-origin":
+ "var(--radix-popper-transform-origin)",
+ "--radix-context-menu-content-available-width":
+ "var(--radix-popper-available-width)",
+ "--radix-context-menu-content-available-height":
+ "var(--radix-popper-available-height)",
+ "--radix-context-menu-trigger-width":
+ "var(--radix-popper-anchor-width)",
+ "--radix-context-menu-trigger-height":
+ "var(--radix-popper-anchor-height)",
+ },
+ }}
+ />
+ );
+ },
+);
+
+ContextMenuContent.displayName = CONTENT_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuGroup
+ * -----------------------------------------------------------------------------------------------*/
+
+const GROUP_NAME = "ContextMenuGroup";
+
+type ContextMenuGroupElement = React.ElementRef;
+type MenuGroupProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Group
+>;
+interface ContextMenuGroupProps extends MenuGroupProps {}
+
+const ContextMenuGroup = React.forwardRef<
+ ContextMenuGroupElement,
+ ContextMenuGroupProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...groupProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+ },
+);
+
+ContextMenuGroup.displayName = GROUP_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuLabel
+ * -----------------------------------------------------------------------------------------------*/
+
+const LABEL_NAME = "ContextMenuLabel";
+
+type ContextMenuLabelElement = React.ElementRef;
+type MenuLabelProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Label
+>;
+interface ContextMenuLabelProps extends MenuLabelProps {}
+
+const ContextMenuLabel = React.forwardRef<
+ ContextMenuLabelElement,
+ ContextMenuLabelProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...labelProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+ },
+);
+
+ContextMenuLabel.displayName = LABEL_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuItem
+ * -----------------------------------------------------------------------------------------------*/
+
+const ITEM_NAME = "ContextMenuItem";
+
+type ContextMenuItemElement = React.ElementRef;
+type MenuItemProps = Radix.ComponentPropsWithoutRef;
+interface ContextMenuItemProps extends MenuItemProps {}
+
+const ContextMenuItem = React.forwardRef<
+ ContextMenuItemElement,
+ ContextMenuItemProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...itemProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+ },
+);
+
+ContextMenuItem.displayName = ITEM_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuCheckboxItem
+ * -----------------------------------------------------------------------------------------------*/
+
+const CHECKBOX_ITEM_NAME = "ContextMenuCheckboxItem";
+
+type ContextMenuCheckboxItemElement = React.ElementRef<
+ typeof MenuPrimitive.CheckboxItem
+>;
+type MenuCheckboxItemProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.CheckboxItem
+>;
+interface ContextMenuCheckboxItemProps extends MenuCheckboxItemProps {}
+
+const ContextMenuCheckboxItem = React.forwardRef<
+ ContextMenuCheckboxItemElement,
+ ContextMenuCheckboxItemProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...checkboxItemProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+});
+
+ContextMenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuRadioGroup
+ * -----------------------------------------------------------------------------------------------*/
+
+const RADIO_GROUP_NAME = "ContextMenuRadioGroup";
+
+type ContextMenuRadioGroupElement = React.ElementRef<
+ typeof MenuPrimitive.RadioGroup
+>;
+type MenuRadioGroupProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.RadioGroup
+>;
+interface ContextMenuRadioGroupProps extends MenuRadioGroupProps {}
+
+const ContextMenuRadioGroup = React.forwardRef<
+ ContextMenuRadioGroupElement,
+ ContextMenuRadioGroupProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...radioGroupProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+});
+
+ContextMenuRadioGroup.displayName = RADIO_GROUP_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuRadioItem
+ * -----------------------------------------------------------------------------------------------*/
+
+const RADIO_ITEM_NAME = "ContextMenuRadioItem";
+
+type ContextMenuRadioItemElement = React.ElementRef<
+ typeof MenuPrimitive.RadioItem
+>;
+type MenuRadioItemProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.RadioItem
+>;
+interface ContextMenuRadioItemProps extends MenuRadioItemProps {}
+
+const ContextMenuRadioItem = React.forwardRef<
+ ContextMenuRadioItemElement,
+ ContextMenuRadioItemProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...radioItemProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+});
+
+ContextMenuRadioItem.displayName = RADIO_ITEM_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuItemIndicator
+ * -----------------------------------------------------------------------------------------------*/
+
+const INDICATOR_NAME = "ContextMenuItemIndicator";
+
+type ContextMenuItemIndicatorElement = React.ElementRef<
+ typeof MenuPrimitive.ItemIndicator
+>;
+type MenuItemIndicatorProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.ItemIndicator
+>;
+interface ContextMenuItemIndicatorProps extends MenuItemIndicatorProps {}
+
+const ContextMenuItemIndicator = React.forwardRef<
+ ContextMenuItemIndicatorElement,
+ ContextMenuItemIndicatorProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...itemIndicatorProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+});
+
+ContextMenuItemIndicator.displayName = INDICATOR_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuSeparator
+ * -----------------------------------------------------------------------------------------------*/
+
+const SEPARATOR_NAME = "ContextMenuSeparator";
+
+type ContextMenuSeparatorElement = React.ElementRef<
+ typeof MenuPrimitive.Separator
+>;
+type MenuSeparatorProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Separator
+>;
+interface ContextMenuSeparatorProps extends MenuSeparatorProps {}
+
+const ContextMenuSeparator = React.forwardRef<
+ ContextMenuSeparatorElement,
+ ContextMenuSeparatorProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...separatorProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+});
+
+ContextMenuSeparator.displayName = SEPARATOR_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuArrow
+ * -----------------------------------------------------------------------------------------------*/
+
+const ARROW_NAME = "ContextMenuArrow";
+
+type ContextMenuArrowElement = React.ElementRef;
+type MenuArrowProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Arrow
+>;
+interface ContextMenuArrowProps extends MenuArrowProps {}
+
+const ContextMenuArrow = React.forwardRef<
+ ContextMenuArrowElement,
+ ContextMenuArrowProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...arrowProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+ },
+);
+
+ContextMenuArrow.displayName = ARROW_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuSub
+ * -----------------------------------------------------------------------------------------------*/
+
+const SUB_NAME = "ContextMenuSub";
+
+interface ContextMenuSubProps {
+ children?: React.ComponentChildren;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?(open: boolean): void;
+}
+
+const ContextMenuSub: React.FC = (
+ props: ScopedProps,
+) => {
+ const {
+ __scopeContextMenu,
+ children,
+ onOpenChange,
+ open: openProp,
+ defaultOpen,
+ } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ const [open, setOpen] = useControllableState({
+ prop: openProp,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+
+ return (
+
+ {children}
+
+ );
+};
+
+ContextMenuSub.displayName = SUB_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuSubTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+const SUB_TRIGGER_NAME = "ContextMenuSubTrigger";
+
+type ContextMenuSubTriggerElement = React.ElementRef<
+ typeof MenuPrimitive.SubTrigger
+>;
+type MenuSubTriggerProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.SubTrigger
+>;
+interface ContextMenuSubTriggerProps extends MenuSubTriggerProps {}
+
+const ContextMenuSubTrigger = React.forwardRef<
+ ContextMenuSubTriggerElement,
+ ContextMenuSubTriggerProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...triggerItemProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+ return (
+
+ );
+});
+
+ContextMenuSubTrigger.displayName = SUB_TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * ContextMenuSubContent
+ * -----------------------------------------------------------------------------------------------*/
+
+const SUB_CONTENT_NAME = "ContextMenuSubContent";
+
+type ContextMenuSubContentElement = React.ElementRef<
+ typeof MenuPrimitive.Content
+>;
+type MenuSubContentProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.SubContent
+>;
+interface ContextMenuSubContentProps extends MenuSubContentProps {}
+
+const ContextMenuSubContent = React.forwardRef<
+ ContextMenuSubContentElement,
+ ContextMenuSubContentProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeContextMenu, ...subContentProps } = props;
+ const menuScope = useMenuScope(__scopeContextMenu);
+
+ return (
+
+ );
+});
+
+ContextMenuSubContent.displayName = SUB_CONTENT_NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+function whenTouchOrPen(
+ handler: React.PointerEventHandler,
+): React.PointerEventHandler {
+ return (
+ event,
+ ) => (event.pointerType !== "mouse" ? handler(event) : undefined);
+}
+
+const Root = ContextMenu;
+const Trigger = ContextMenuTrigger;
+const Portal = ContextMenuPortal;
+const Content = ContextMenuContent;
+const Group = ContextMenuGroup;
+const Label = ContextMenuLabel;
+const Item = ContextMenuItem;
+const CheckboxItem = ContextMenuCheckboxItem;
+const RadioGroup = ContextMenuRadioGroup;
+const RadioItem = ContextMenuRadioItem;
+const ItemIndicator = ContextMenuItemIndicator;
+const Separator = ContextMenuSeparator;
+const Arrow = ContextMenuArrow;
+const Sub = ContextMenuSub;
+const SubTrigger = ContextMenuSubTrigger;
+const SubContent = ContextMenuSubContent;
+
+export {
+ Arrow,
+ CheckboxItem,
+ Content,
+ //
+ ContextMenu,
+ ContextMenuArrow,
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuGroup,
+ ContextMenuItem,
+ ContextMenuItemIndicator,
+ ContextMenuLabel,
+ ContextMenuPortal,
+ ContextMenuRadioGroup,
+ ContextMenuRadioItem,
+ ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuTrigger,
+ createContextMenuScope,
+ Group,
+ Item,
+ ItemIndicator,
+ Label,
+ Portal,
+ RadioGroup,
+ RadioItem,
+ //
+ Root,
+ Separator,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger,
+};
+export type {
+ ContextMenuArrowProps,
+ ContextMenuCheckboxItemProps,
+ ContextMenuContentProps,
+ ContextMenuGroupProps,
+ ContextMenuItemIndicatorProps,
+ ContextMenuItemProps,
+ ContextMenuLabelProps,
+ ContextMenuPortalProps,
+ ContextMenuProps,
+ ContextMenuRadioGroupProps,
+ ContextMenuRadioItemProps,
+ ContextMenuSeparatorProps,
+ ContextMenuSubContentProps,
+ ContextMenuSubProps,
+ ContextMenuSubTriggerProps,
+ ContextMenuTriggerProps,
+};
diff --git a/pkg/radix-ui-primitives/preact/context-menu/mod.ts b/pkg/radix-ui-primitives/preact/context-menu/mod.ts
new file mode 100644
index 0000000..70d203c
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/context-menu/mod.ts
@@ -0,0 +1,55 @@
+export {
+ Arrow,
+ CheckboxItem,
+ Content,
+ //
+ ContextMenu,
+ ContextMenuArrow,
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuGroup,
+ ContextMenuItem,
+ ContextMenuItemIndicator,
+ ContextMenuLabel,
+ ContextMenuPortal,
+ ContextMenuRadioGroup,
+ ContextMenuRadioItem,
+ ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuTrigger,
+ createContextMenuScope,
+ Group,
+ Item,
+ ItemIndicator,
+ Label,
+ Portal,
+ RadioGroup,
+ RadioItem,
+ //
+ Root,
+ Separator,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger,
+} from "./ContextMenu.tsx";
+export type {
+ ContextMenuArrowProps,
+ ContextMenuCheckboxItemProps,
+ ContextMenuContentProps,
+ ContextMenuGroupProps,
+ ContextMenuItemIndicatorProps,
+ ContextMenuItemProps,
+ ContextMenuLabelProps,
+ ContextMenuPortalProps,
+ ContextMenuProps,
+ ContextMenuRadioGroupProps,
+ ContextMenuRadioItemProps,
+ ContextMenuSeparatorProps,
+ ContextMenuSubContentProps,
+ ContextMenuSubProps,
+ ContextMenuSubTriggerProps,
+ ContextMenuTriggerProps,
+} from "./ContextMenu.tsx";
diff --git a/pkg/radix-ui-primitives/preact/context/createContext.tsx b/pkg/radix-ui-primitives/preact/context/createContext.tsx
new file mode 100644
index 0000000..3e339e4
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/context/createContext.tsx
@@ -0,0 +1,171 @@
+import * as React from "preact/compat";
+
+function createContext(
+ rootComponentName: string,
+ defaultContext?: ContextValueType,
+) {
+ const Context = React.createContext(
+ defaultContext,
+ );
+
+ function Provider(
+ props: ContextValueType & { children: React.ComponentChildren },
+ ) {
+ const { children, ...context } = props;
+ // Only re-memoize when prop values change
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const value = React.useMemo(
+ () => context,
+ Object.values(context),
+ ) as ContextValueType;
+ return {children};
+ }
+
+ function useContext(consumerName: string) {
+ const context = React.useContext(Context);
+ if (context) return context;
+ if (defaultContext !== undefined) return defaultContext;
+ // if a defaultContext wasn't specified, it's a required context.
+ throw new Error(
+ `\`${consumerName}\` must be used within \`${rootComponentName}\``,
+ );
+ }
+
+ Provider.displayName = rootComponentName + "Provider";
+ return [Provider, useContext] as const;
+}
+
+/* -------------------------------------------------------------------------------------------------
+ * createContextScope
+ * -----------------------------------------------------------------------------------------------*/
+
+type Scope = { [scopeName: string]: React.Context[] } | undefined;
+type ScopeHook = (scope: Scope) => { [__scopeProp: string]: Scope };
+interface CreateScope {
+ scopeName: string;
+ (): ScopeHook;
+}
+
+function createContextScope(
+ scopeName: string,
+ createContextScopeDeps: CreateScope[] = [],
+) {
+ let defaultContexts: any[] = [];
+
+ /* -----------------------------------------------------------------------------------------------
+ * createContext
+ * ---------------------------------------------------------------------------------------------*/
+
+ function createContext(
+ rootComponentName: string,
+ defaultContext?: ContextValueType,
+ ) {
+ const BaseContext = React.createContext(
+ defaultContext,
+ );
+ const index = defaultContexts.length;
+ defaultContexts = [...defaultContexts, defaultContext];
+
+ function Provider(
+ props: ContextValueType & {
+ scope: Scope;
+ children: React.ComponentChildren;
+ },
+ ) {
+ const { scope, children, ...context } = props;
+ const Context = scope?.[scopeName][index] || BaseContext;
+ // Only re-memoize when prop values change
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const value = React.useMemo(
+ () => context,
+ Object.values(context),
+ ) as ContextValueType;
+ return {children};
+ }
+
+ function useContext(
+ consumerName: string,
+ scope: Scope,
+ ) {
+ const Context = scope?.[scopeName][index] || BaseContext;
+ const context = React.useContext(Context);
+ if (context) return context;
+ if (defaultContext !== undefined) return defaultContext;
+ // if a defaultContext wasn't specified, it's a required context.
+ throw new Error(
+ `\`${consumerName}\` must be used within \`${rootComponentName}\``,
+ );
+ }
+
+ Provider.displayName = rootComponentName + "Provider";
+ return [Provider, useContext] as const;
+ }
+
+ /* -----------------------------------------------------------------------------------------------
+ * createScope
+ * ---------------------------------------------------------------------------------------------*/
+
+ const createScope: CreateScope = () => {
+ const scopeContexts = defaultContexts.map((defaultContext) => {
+ return React.createContext(defaultContext);
+ });
+ return function useScope(scope: Scope) {
+ const contexts = scope?.[scopeName] || scopeContexts;
+ return React.useMemo(
+ () => ({
+ [`__scope${scopeName}`]: { ...scope, [scopeName]: contexts },
+ }),
+ [scope, contexts],
+ );
+ };
+ };
+
+ createScope.scopeName = scopeName;
+ return [
+ createContext,
+ composeContextScopes(createScope, ...createContextScopeDeps),
+ ] as const;
+}
+
+/* -------------------------------------------------------------------------------------------------
+ * composeContextScopes
+ * -----------------------------------------------------------------------------------------------*/
+
+function composeContextScopes(...scopes: CreateScope[]) {
+ const baseScope = scopes[0];
+ if (scopes.length === 1) return baseScope;
+
+ const createScope: CreateScope = () => {
+ const scopeHooks = scopes.map((createScope) => ({
+ useScope: createScope(),
+ scopeName: createScope.scopeName,
+ }));
+
+ return function useComposedScopes(overrideScopes) {
+ const nextScopes = scopeHooks.reduce(
+ (nextScopes, { useScope, scopeName }) => {
+ // We are calling a hook inside a callback which React warns against to avoid inconsistent
+ // renders, however, scoping doesn't have render side effects so we ignore the rule.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const scopeProps = useScope(overrideScopes);
+ const currentScope = scopeProps[`__scope${scopeName}`];
+ return { ...nextScopes, ...currentScope };
+ },
+ {},
+ );
+
+ return React.useMemo(
+ () => ({ [`__scope${baseScope.scopeName}`]: nextScopes }),
+ [nextScopes],
+ );
+ };
+ };
+
+ createScope.scopeName = baseScope.scopeName;
+ return createScope;
+}
+
+/* -----------------------------------------------------------------------------------------------*/
+
+export { createContext, createContextScope };
+export type { CreateScope, Scope };
diff --git a/pkg/radix-ui-primitives/preact/context/mod.ts b/pkg/radix-ui-primitives/preact/context/mod.ts
new file mode 100644
index 0000000..e850b00
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/context/mod.ts
@@ -0,0 +1,2 @@
+export { createContext, createContextScope } from "./createContext.tsx";
+export type { CreateScope, Scope } from "./createContext.tsx";
diff --git a/pkg/radix-ui-primitives/preact/dialog/Dialog.tsx b/pkg/radix-ui-primitives/preact/dialog/Dialog.tsx
new file mode 100644
index 0000000..b89e691
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/dialog/Dialog.tsx
@@ -0,0 +1,688 @@
+import * as React from "preact/compat";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { createContext, createContextScope } from "../context/mod.ts";
+import { useId } from "../id/mod.ts";
+import { useControllableState } from "../use-controllable-state/mod.ts";
+import { DismissableLayer } from "../dismissable-layer/mod.ts";
+import { FocusScope } from "../focus-scope/mod.ts";
+import { Portal as PortalPrimitive } from "../portal/mod.ts";
+import { Presence } from "../presence/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+import { useFocusGuards } from "../focus-guards/mod.ts";
+import { RemoveScroll } from "react-remove-scroll";
+import { hideOthers } from "aria-hidden";
+import { Slot } from "../slot/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+
+/* -------------------------------------------------------------------------------------------------
+ * Dialog
+ * -----------------------------------------------------------------------------------------------*/
+
+const DIALOG_NAME = "Dialog";
+
+type ScopedProps = P & { __scopeDialog?: Scope };
+const [createDialogContext, createDialogScope] = createContextScope(
+ DIALOG_NAME,
+);
+
+type DialogContextValue = {
+ triggerRef: React.RefObject;
+ contentRef: React.RefObject;
+ contentId: string;
+ titleId: string;
+ descriptionId: string;
+ open: boolean;
+ onOpenChange(open: boolean): void;
+ onOpenToggle(): void;
+ modal: boolean;
+};
+
+const [DialogProvider, useDialogContext] = createDialogContext<
+ DialogContextValue
+>(DIALOG_NAME);
+
+interface DialogProps {
+ children?: React.ComponentChildren;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?(open: boolean): void;
+ modal?: boolean;
+}
+
+const Dialog: React.FC = (
+ props: ScopedProps,
+) => {
+ const {
+ __scopeDialog,
+ children,
+ open: openProp,
+ defaultOpen,
+ onOpenChange,
+ modal = true,
+ } = props;
+ const triggerRef = React.useRef(null);
+ const contentRef = React.useRef(null);
+ const [open = false, setOpen] = useControllableState({
+ prop: openProp,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+
+ return (
+ setOpen((prevOpen) => !prevOpen),
+ [
+ setOpen,
+ ],
+ )}
+ modal={modal}
+ >
+ {children}
+
+ );
+};
+
+Dialog.displayName = DIALOG_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DialogTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+const TRIGGER_NAME = "DialogTrigger";
+
+type DialogTriggerElement = React.ElementRef;
+type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef<
+ typeof Primitive.button
+>;
+interface DialogTriggerProps extends PrimitiveButtonProps {}
+
+const DialogTrigger = React.forwardRef<
+ DialogTriggerElement,
+ DialogTriggerProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDialog, ...triggerProps } = props;
+ const context = useDialogContext(TRIGGER_NAME, __scopeDialog);
+ const composedTriggerRef = useComposedRefs(
+ forwardedRef,
+ context.triggerRef,
+ );
+ return (
+
+ );
+ },
+);
+
+DialogTrigger.displayName = TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DialogPortal
+ * -----------------------------------------------------------------------------------------------*/
+
+const PORTAL_NAME = "DialogPortal";
+
+type PortalContextValue = { forceMount?: true };
+const [PortalProvider, usePortalContext] = createDialogContext<
+ PortalContextValue
+>(PORTAL_NAME, {
+ forceMount: undefined,
+});
+
+type PortalProps = Radix.ComponentPropsWithoutRef;
+interface DialogPortalProps {
+ children?: React.ComponentChildren;
+ /**
+ * Specify a container element to portal the content into.
+ */
+ container?: PortalProps["container"];
+ /**
+ * Used to force mounting when more control is needed. Useful when
+ * controlling animation with React animation libraries.
+ */
+ forceMount?: true;
+}
+
+const DialogPortal: React.FC = (
+ props: ScopedProps,
+) => {
+ const { __scopeDialog, forceMount, children, container } = props;
+ const context = useDialogContext(PORTAL_NAME, __scopeDialog);
+ return (
+
+ {React.Children.map(
+ children,
+ (child) => (
+
+
+ {child}
+
+
+ ),
+ )}
+
+ );
+};
+
+DialogPortal.displayName = PORTAL_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DialogOverlay
+ * -----------------------------------------------------------------------------------------------*/
+
+const OVERLAY_NAME = "DialogOverlay";
+
+type DialogOverlayElement = DialogOverlayImplElement;
+interface DialogOverlayProps extends DialogOverlayImplProps {
+ /**
+ * Used to force mounting when more control is needed. Useful when
+ * controlling animation with React animation libraries.
+ */
+ forceMount?: true;
+}
+
+const DialogOverlay = React.forwardRef<
+ DialogOverlayElement,
+ DialogOverlayProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const portalContext = usePortalContext(OVERLAY_NAME, props.__scopeDialog);
+ const { forceMount = portalContext.forceMount, ...overlayProps } = props;
+ const context = useDialogContext(OVERLAY_NAME, props.__scopeDialog);
+ return context.modal
+ ? (
+
+
+
+ )
+ : null;
+ },
+);
+
+DialogOverlay.displayName = OVERLAY_NAME;
+
+type DialogOverlayImplElement = React.ElementRef;
+type PrimitiveDivProps = Radix.ComponentPropsWithoutRef;
+interface DialogOverlayImplProps extends PrimitiveDivProps {}
+
+const DialogOverlayImpl = React.forwardRef<
+ DialogOverlayImplElement,
+ DialogOverlayImplProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDialog, ...overlayProps } = props;
+ const context = useDialogContext(OVERLAY_NAME, __scopeDialog);
+ return (
+ // Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll`
+ // ie. when `Overlay` and `Content` are siblings
+
+
+
+ );
+ },
+);
+
+/* -------------------------------------------------------------------------------------------------
+ * DialogContent
+ * -----------------------------------------------------------------------------------------------*/
+
+const CONTENT_NAME = "DialogContent";
+
+type DialogContentElement = DialogContentTypeElement;
+interface DialogContentProps extends DialogContentTypeProps {
+ /**
+ * Used to force mounting when more control is needed. Useful when
+ * controlling animation with React animation libraries.
+ */
+ forceMount?: true;
+}
+
+const DialogContent = React.forwardRef<
+ DialogContentElement,
+ DialogContentProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const portalContext = usePortalContext(CONTENT_NAME, props.__scopeDialog);
+ const { forceMount = portalContext.forceMount, ...contentProps } = props;
+ const context = useDialogContext(CONTENT_NAME, props.__scopeDialog);
+ return (
+
+ {context.modal
+ ?
+ : }
+
+ );
+ },
+);
+
+DialogContent.displayName = CONTENT_NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+type DialogContentTypeElement = DialogContentImplElement;
+interface DialogContentTypeProps
+ extends
+ Omit {}
+
+const DialogContentModal = React.forwardRef<
+ DialogContentTypeElement,
+ DialogContentTypeProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const context = useDialogContext(CONTENT_NAME, props.__scopeDialog);
+ const contentRef = React.useRef(null);
+ const composedRefs = useComposedRefs(
+ forwardedRef,
+ context.contentRef,
+ contentRef,
+ );
+
+ // aria-hide everything except the content (better supported equivalent to setting aria-modal)
+ React.useEffect(() => {
+ const content = contentRef.current;
+ if (content) return hideOthers(content);
+ }, []);
+
+ return (
+ {
+ event.preventDefault();
+ context.triggerRef.current?.focus();
+ },
+ )}
+ onPointerDownOutside={composeEventHandlers(
+ props.onPointerDownOutside,
+ (event) => {
+ const originalEvent = event.detail.originalEvent;
+ const ctrlLeftClick = originalEvent.button === 0 &&
+ originalEvent.ctrlKey === true;
+ const isRightClick = originalEvent.button === 2 || ctrlLeftClick;
+
+ // If the event is a right-click, we shouldn't close because
+ // it is effectively as if we right-clicked the `Overlay`.
+ if (isRightClick) event.preventDefault();
+ },
+ )}
+ // When focus is trapped, a `focusout` event may still happen.
+ // We make sure we don't trigger our `onDismiss` in such case.
+ onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) =>
+ event.preventDefault())}
+ />
+ );
+ },
+);
+
+/* -----------------------------------------------------------------------------------------------*/
+
+const DialogContentNonModal = React.forwardRef<
+ DialogContentTypeElement,
+ DialogContentTypeProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const context = useDialogContext(CONTENT_NAME, props.__scopeDialog);
+ const hasInteractedOutsideRef = React.useRef(false);
+ const hasPointerDownOutsideRef = React.useRef(false);
+
+ return (
+ {
+ props.onCloseAutoFocus?.(event);
+
+ if (!event.defaultPrevented) {
+ if (!hasInteractedOutsideRef.current) {
+ context.triggerRef.current
+ ?.focus();
+ }
+ // Always prevent auto focus because we either focus manually or want user agent focus
+ event.preventDefault();
+ }
+
+ hasInteractedOutsideRef.current = false;
+ hasPointerDownOutsideRef.current = false;
+ }}
+ onInteractOutside={(event) => {
+ props.onInteractOutside?.(event);
+
+ if (!event.defaultPrevented) {
+ hasInteractedOutsideRef.current = true;
+ if (event.detail.originalEvent.type === "pointerdown") {
+ hasPointerDownOutsideRef.current = true;
+ }
+ }
+
+ // Prevent dismissing when clicking the trigger.
+ // As the trigger is already setup to close, without doing so would
+ // cause it to close and immediately open.
+ const target = event.target as HTMLElement;
+ const targetIsTrigger = context.triggerRef.current?.contains(target);
+ if (targetIsTrigger) event.preventDefault();
+
+ // On Safari if the trigger is inside a container with tabIndex={0}, when clicked
+ // we will get the pointer down outside event on the trigger, but then a subsequent
+ // focus outside event on the container, we ignore any focus outside event when we've
+ // already had a pointer down outside event.
+ if (
+ event.detail.originalEvent.type === "focusin" &&
+ hasPointerDownOutsideRef.current
+ ) {
+ event.preventDefault();
+ }
+ }}
+ />
+ );
+ },
+);
+
+/* -----------------------------------------------------------------------------------------------*/
+
+type DialogContentImplElement = React.ElementRef;
+type DismissableLayerProps = Radix.ComponentPropsWithoutRef<
+ typeof DismissableLayer
+>;
+type FocusScopeProps = Radix.ComponentPropsWithoutRef;
+interface DialogContentImplProps
+ extends Omit {
+ /**
+ * When `true`, focus cannot escape the `Content` via keyboard,
+ * pointer, or a programmatic focus.
+ * @defaultValue false
+ */
+ trapFocus?: FocusScopeProps["trapped"];
+
+ /**
+ * Event handler called when auto-focusing on open.
+ * Can be prevented.
+ */
+ onOpenAutoFocus?: FocusScopeProps["onMountAutoFocus"];
+
+ /**
+ * Event handler called when auto-focusing on close.
+ * Can be prevented.
+ */
+ onCloseAutoFocus?: FocusScopeProps["onUnmountAutoFocus"];
+}
+
+const DialogContentImpl = React.forwardRef<
+ DialogContentImplElement,
+ DialogContentImplProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const {
+ __scopeDialog,
+ trapFocus,
+ onOpenAutoFocus,
+ onCloseAutoFocus,
+ ...contentProps
+ } = props;
+ const context = useDialogContext(CONTENT_NAME, __scopeDialog);
+ const contentRef = React.useRef(null);
+ const composedRefs = useComposedRefs(forwardedRef, contentRef);
+
+ // Make sure the whole tree has focus guards as our `Dialog` will be
+ // the last element in the DOM (beacuse of the `Portal`)
+ useFocusGuards();
+
+ return (
+ <>
+
+ context.onOpenChange(false)}
+ />
+
+ {process.env.NODE_ENV !== "production" && (
+ <>
+
+
+ >
+ )}
+ >
+ );
+ },
+);
+
+/* -------------------------------------------------------------------------------------------------
+ * DialogTitle
+ * -----------------------------------------------------------------------------------------------*/
+
+const TITLE_NAME = "DialogTitle";
+
+type DialogTitleElement = React.ElementRef;
+type PrimitiveHeading2Props = Radix.ComponentPropsWithoutRef<
+ typeof Primitive.h2
+>;
+interface DialogTitleProps extends PrimitiveHeading2Props {}
+
+const DialogTitle = React.forwardRef<
+ DialogTitleElement,
+ DialogTitleProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDialog, ...titleProps } = props;
+ const context = useDialogContext(TITLE_NAME, __scopeDialog);
+ return (
+
+ );
+ },
+);
+
+DialogTitle.displayName = TITLE_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DialogDescription
+ * -----------------------------------------------------------------------------------------------*/
+
+const DESCRIPTION_NAME = "DialogDescription";
+
+type DialogDescriptionElement = React.ElementRef;
+type PrimitiveParagraphProps = Radix.ComponentPropsWithoutRef<
+ typeof Primitive.p
+>;
+interface DialogDescriptionProps extends PrimitiveParagraphProps {}
+
+const DialogDescription = React.forwardRef<
+ DialogDescriptionElement,
+ DialogDescriptionProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDialog, ...descriptionProps } = props;
+ const context = useDialogContext(DESCRIPTION_NAME, __scopeDialog);
+ return (
+
+ );
+ },
+);
+
+DialogDescription.displayName = DESCRIPTION_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DialogClose
+ * -----------------------------------------------------------------------------------------------*/
+
+const CLOSE_NAME = "DialogClose";
+
+type DialogCloseElement = React.ElementRef;
+interface DialogCloseProps extends PrimitiveButtonProps {}
+
+const DialogClose = React.forwardRef<
+ DialogCloseElement,
+ DialogCloseProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDialog, ...closeProps } = props;
+ const context = useDialogContext(CLOSE_NAME, __scopeDialog);
+ return (
+
+ context.onOpenChange(false))}
+ />
+ );
+ },
+);
+
+DialogClose.displayName = CLOSE_NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+function getState(open: boolean) {
+ return open ? "open" : "closed";
+}
+
+const TITLE_WARNING_NAME = "DialogTitleWarning";
+
+const [WarningProvider, useWarningContext] = createContext(TITLE_WARNING_NAME, {
+ contentName: CONTENT_NAME,
+ titleName: TITLE_NAME,
+ docsSlug: "dialog",
+});
+
+type TitleWarningProps = { titleId?: string };
+
+const TitleWarning: React.FC = ({ titleId }) => {
+ const titleWarningContext = useWarningContext(TITLE_WARNING_NAME);
+
+ const MESSAGE =
+ `\`${titleWarningContext.contentName}\` requires a \`${titleWarningContext.titleName}\` for the component to be accessible for screen reader users.
+
+If you want to hide the \`${titleWarningContext.titleName}\`, you can wrap it with our VisuallyHidden component.
+
+For more information, see https://radix-ui.com/primitives/docs/components/${titleWarningContext.docsSlug}`;
+
+ React.useEffect(() => {
+ if (titleId) {
+ const hasTitle = document.getElementById(titleId);
+ if (!hasTitle) throw new Error(MESSAGE);
+ }
+ }, [MESSAGE, titleId]);
+
+ return null;
+};
+
+const DESCRIPTION_WARNING_NAME = "DialogDescriptionWarning";
+
+type DescriptionWarningProps = {
+ contentRef: React.RefObject;
+ descriptionId?: string;
+};
+
+const DescriptionWarning: React.FC = (
+ { contentRef, descriptionId },
+) => {
+ const descriptionWarningContext = useWarningContext(DESCRIPTION_WARNING_NAME);
+ const MESSAGE =
+ `Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${descriptionWarningContext.contentName}}.`;
+
+ React.useEffect(() => {
+ const describedById = contentRef.current?.getAttribute("aria-describedby");
+ // if we have an id and the user hasn't set aria-describedby={undefined}
+ if (descriptionId && describedById) {
+ const hasDescription = document.getElementById(descriptionId);
+ if (!hasDescription) console.warn(MESSAGE);
+ }
+ }, [MESSAGE, contentRef, descriptionId]);
+
+ return null;
+};
+
+const Root = Dialog;
+const Trigger = DialogTrigger;
+const Portal = DialogPortal;
+const Overlay = DialogOverlay;
+const Content = DialogContent;
+const Title = DialogTitle;
+const Description = DialogDescription;
+const Close = DialogClose;
+
+export {
+ Close,
+ Content,
+ createDialogScope,
+ Description,
+ //
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+ Overlay,
+ Portal,
+ //
+ Root,
+ Title,
+ Trigger,
+ //
+ WarningProvider,
+};
+export type {
+ DialogCloseProps,
+ DialogContentProps,
+ DialogDescriptionProps,
+ DialogOverlayProps,
+ DialogPortalProps,
+ DialogProps,
+ DialogTitleProps,
+ DialogTriggerProps,
+};
diff --git a/pkg/radix-ui-primitives/preact/dialog/mod.ts b/pkg/radix-ui-primitives/preact/dialog/mod.ts
new file mode 100644
index 0000000..85e3747
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/dialog/mod.ts
@@ -0,0 +1,33 @@
+export {
+ Close,
+ Content,
+ createDialogScope,
+ Description,
+ //
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+ Overlay,
+ Portal,
+ //
+ Root,
+ Title,
+ Trigger,
+ //
+ WarningProvider,
+} from "./Dialog.tsx";
+export type {
+ DialogCloseProps,
+ DialogContentProps,
+ DialogDescriptionProps,
+ DialogOverlayProps,
+ DialogPortalProps,
+ DialogProps,
+ DialogTitleProps,
+ DialogTriggerProps,
+} from "./Dialog.tsx";
diff --git a/pkg/radix-ui-primitives/preact/direction/Direction.tsx b/pkg/radix-ui-primitives/preact/direction/Direction.tsx
new file mode 100644
index 0000000..825bb9d
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/direction/Direction.tsx
@@ -0,0 +1,38 @@
+import * as React from "preact/compat";
+
+type Direction = "ltr" | "rtl";
+const DirectionContext = React.createContext(undefined);
+
+/* -------------------------------------------------------------------------------------------------
+ * Direction
+ * -----------------------------------------------------------------------------------------------*/
+
+interface DirectionProviderProps {
+ children?: React.ComponentChildren;
+ dir: Direction;
+}
+const DirectionProvider: React.FC = (props) => {
+ const { dir, children } = props;
+ return (
+
+ {children}
+
+ );
+};
+
+/* -----------------------------------------------------------------------------------------------*/
+
+function useDirection(localDir?: Direction) {
+ const globalDir = React.useContext(DirectionContext);
+ return localDir || globalDir || "ltr";
+}
+
+const Provider = DirectionProvider;
+
+export {
+ //
+ DirectionProvider,
+ //
+ Provider,
+ useDirection,
+};
diff --git a/pkg/radix-ui-primitives/preact/direction/mod.ts b/pkg/radix-ui-primitives/preact/direction/mod.ts
new file mode 100644
index 0000000..3e072ef
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/direction/mod.ts
@@ -0,0 +1,7 @@
+export {
+ //
+ DirectionProvider,
+ //
+ Provider,
+ useDirection,
+} from "./Direction.tsx";
diff --git a/pkg/radix-ui-primitives/preact/dismissable-layer/DismissableLayer.tsx b/pkg/radix-ui-primitives/preact/dismissable-layer/DismissableLayer.tsx
new file mode 100644
index 0000000..709ff44
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/dismissable-layer/DismissableLayer.tsx
@@ -0,0 +1,403 @@
+import * as React from "preact/compat";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { dispatchDiscreteCustomEvent, Primitive } from "../primitive/mod.ts";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { useCallbackRef } from "../use-callback-ref/mod.ts";
+import { useEscapeKeydown } from "../use-escape-keydown/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+
+/* -------------------------------------------------------------------------------------------------
+ * DismissableLayer
+ * -----------------------------------------------------------------------------------------------*/
+
+const DISMISSABLE_LAYER_NAME = "DismissableLayer";
+const CONTEXT_UPDATE = "dismissableLayer.update";
+const POINTER_DOWN_OUTSIDE = "dismissableLayer.pointerDownOutside";
+const FOCUS_OUTSIDE = "dismissableLayer.focusOutside";
+
+let originalBodyPointerEvents: string;
+
+const DismissableLayerContext = React.createContext({
+ layers: new Set(),
+ layersWithOutsidePointerEventsDisabled: new Set(),
+ branches: new Set(),
+});
+
+type DismissableLayerElement = React.ElementRef;
+type PrimitiveDivProps = Radix.ComponentPropsWithoutRef;
+interface DismissableLayerProps extends PrimitiveDivProps {
+ /**
+ * When `true`, hover/focus/click interactions will be disabled on elements outside
+ * the `DismissableLayer`. Users will need to click twice on outside elements to
+ * interact with them: once to close the `DismissableLayer`, and again to trigger the element.
+ */
+ disableOutsidePointerEvents?: boolean;
+ /**
+ * Event handler called when the escape key is down.
+ * Can be prevented.
+ */
+ onEscapeKeyDown?: (event: KeyboardEvent) => void;
+ /**
+ * Event handler called when the a `pointerdown` event happens outside of the `DismissableLayer`.
+ * Can be prevented.
+ */
+ onPointerDownOutside?: (event: PointerDownOutsideEvent) => void;
+ /**
+ * Event handler called when the focus moves outside of the `DismissableLayer`.
+ * Can be prevented.
+ */
+ onFocusOutside?: (event: FocusOutsideEvent) => void;
+ /**
+ * Event handler called when an interaction happens outside the `DismissableLayer`.
+ * Specifically, when a `pointerdown` event happens outside or focus moves outside of it.
+ * Can be prevented.
+ */
+ onInteractOutside?: (
+ event: PointerDownOutsideEvent | FocusOutsideEvent,
+ ) => void;
+ /**
+ * Handler called when the `DismissableLayer` should be dismissed
+ */
+ onDismiss?: () => void;
+}
+
+const DismissableLayer = React.forwardRef<
+ DismissableLayerElement,
+ DismissableLayerProps
+>(
+ (props, forwardedRef) => {
+ const {
+ disableOutsidePointerEvents = false,
+ onEscapeKeyDown,
+ onPointerDownOutside,
+ onFocusOutside,
+ onInteractOutside,
+ onDismiss,
+ ...layerProps
+ } = props;
+ const context = React.useContext(DismissableLayerContext);
+ const [node, setNode] = React.useState(
+ null,
+ );
+ const ownerDocument = node?.ownerDocument ?? globalThis?.document;
+ const [, force] = React.useState({});
+ const composedRefs = useComposedRefs(forwardedRef, (node) => setNode(node));
+ const layers = Array.from(context.layers);
+ const [highestLayerWithOutsidePointerEventsDisabled] = [
+ ...context.layersWithOutsidePointerEventsDisabled,
+ ].slice(-1); // prettier-ignore
+ const highestLayerWithOutsidePointerEventsDisabledIndex = layers.indexOf(
+ highestLayerWithOutsidePointerEventsDisabled,
+ ); // prettier-ignore
+ const index = node ? layers.indexOf(node) : -1;
+ const isBodyPointerEventsDisabled =
+ context.layersWithOutsidePointerEventsDisabled.size > 0;
+ const isPointerEventsEnabled =
+ index >= highestLayerWithOutsidePointerEventsDisabledIndex;
+
+ const pointerDownOutside = usePointerDownOutside((event) => {
+ const target = event.target as HTMLElement;
+ const isPointerDownOnBranch = [...context.branches].some((branch) =>
+ branch.contains(target)
+ );
+ if (!isPointerEventsEnabled || isPointerDownOnBranch) return;
+ onPointerDownOutside?.(event);
+ onInteractOutside?.(event);
+ if (!event.defaultPrevented) onDismiss?.();
+ }, ownerDocument);
+
+ const focusOutside = useFocusOutside((event) => {
+ const target = event.target as HTMLElement;
+ const isFocusInBranch = [...context.branches].some((branch) =>
+ branch.contains(target)
+ );
+ if (isFocusInBranch) return;
+ onFocusOutside?.(event);
+ onInteractOutside?.(event);
+ if (!event.defaultPrevented) onDismiss?.();
+ }, ownerDocument);
+
+ useEscapeKeydown((event) => {
+ const isHighestLayer = index === context.layers.size - 1;
+ if (!isHighestLayer) return;
+ onEscapeKeyDown?.(event);
+ if (!event.defaultPrevented && onDismiss) {
+ event.preventDefault();
+ onDismiss();
+ }
+ }, ownerDocument);
+
+ React.useEffect(() => {
+ if (!node) return;
+ if (disableOutsidePointerEvents) {
+ if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
+ originalBodyPointerEvents = ownerDocument.body.style.pointerEvents;
+ ownerDocument.body.style.pointerEvents = "none";
+ }
+ context.layersWithOutsidePointerEventsDisabled.add(node);
+ }
+ context.layers.add(node);
+ dispatchUpdate();
+ return () => {
+ if (
+ disableOutsidePointerEvents &&
+ context.layersWithOutsidePointerEventsDisabled.size === 1
+ ) {
+ ownerDocument.body.style.pointerEvents = originalBodyPointerEvents;
+ }
+ };
+ }, [node, ownerDocument, disableOutsidePointerEvents, context]);
+
+ /**
+ * We purposefully prevent combining this effect with the `disableOutsidePointerEvents` effect
+ * because a change to `disableOutsidePointerEvents` would remove this layer from the stack
+ * and add it to the end again so the layering order wouldn't be _creation order_.
+ * We only want them to be removed from context stacks when unmounted.
+ */
+ React.useEffect(() => {
+ return () => {
+ if (!node) return;
+ context.layers.delete(node);
+ context.layersWithOutsidePointerEventsDisabled.delete(node);
+ dispatchUpdate();
+ };
+ }, [node, context]);
+
+ React.useEffect(() => {
+ const handleUpdate = () => force({});
+ document.addEventListener(CONTEXT_UPDATE, handleUpdate);
+ return () => document.removeEventListener(CONTEXT_UPDATE, handleUpdate);
+ }, []);
+
+ return (
+
+ );
+ },
+);
+
+DismissableLayer.displayName = DISMISSABLE_LAYER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DismissableLayerBranch
+ * -----------------------------------------------------------------------------------------------*/
+
+const BRANCH_NAME = "DismissableLayerBranch";
+
+type DismissableLayerBranchElement = React.ElementRef;
+interface DismissableLayerBranchProps extends PrimitiveDivProps {}
+
+const DismissableLayerBranch = React.forwardRef<
+ DismissableLayerBranchElement,
+ DismissableLayerBranchProps
+>((props, forwardedRef) => {
+ const context = React.useContext(DismissableLayerContext);
+ const ref = React.useRef(null);
+ const composedRefs = useComposedRefs(forwardedRef, ref);
+
+ React.useEffect(() => {
+ const node = ref.current;
+ if (node) {
+ context.branches.add(node);
+ return () => {
+ context.branches.delete(node);
+ };
+ }
+ }, [context.branches]);
+
+ return ;
+});
+
+DismissableLayerBranch.displayName = BRANCH_NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>;
+type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>;
+
+/**
+ * Listens for `pointerdown` outside a react subtree. We use `pointerdown` rather than `pointerup`
+ * to mimic layer dismissing behaviour present in OS.
+ * Returns props to pass to the node we want to check for outside events.
+ */
+function usePointerDownOutside(
+ onPointerDownOutside?: (event: PointerDownOutsideEvent) => void,
+ ownerDocument: Document = globalThis?.document,
+) {
+ const handlePointerDownOutside = useCallbackRef(
+ onPointerDownOutside,
+ ) as EventListener;
+ const isPointerInsideReactTreeRef = React.useRef(false);
+ const handleClickRef = React.useRef(() => {});
+
+ React.useEffect(() => {
+ const handlePointerDown = (event: PointerEvent) => {
+ if (event.target && !isPointerInsideReactTreeRef.current) {
+ const eventDetail = { originalEvent: event };
+
+ function handleAndDispatchPointerDownOutsideEvent() {
+ handleAndDispatchCustomEvent(
+ POINTER_DOWN_OUTSIDE,
+ handlePointerDownOutside,
+ eventDetail,
+ { discrete: true },
+ );
+ }
+
+ /**
+ * On touch devices, we need to wait for a click event because browsers implement
+ * a ~350ms delay between the time the user stops touching the display and when the
+ * browser executres events. We need to ensure we don't reactivate pointer-events within
+ * this timeframe otherwise the browser may execute events that should have been prevented.
+ *
+ * Additionally, this also lets us deal automatically with cancellations when a click event
+ * isn't raised because the page was considered scrolled/drag-scrolled, long-pressed, etc.
+ *
+ * This is why we also continuously remove the previous listener, because we cannot be
+ * certain that it was raised, and therefore cleaned-up.
+ */
+ if (event.pointerType === "touch") {
+ ownerDocument.removeEventListener("click", handleClickRef.current);
+ handleClickRef.current = handleAndDispatchPointerDownOutsideEvent;
+ ownerDocument.addEventListener("click", handleClickRef.current, {
+ once: true,
+ });
+ } else {
+ handleAndDispatchPointerDownOutsideEvent();
+ }
+ } else {
+ // We need to remove the event listener in case the outside click has been canceled.
+ // See: https://github.com/radix-ui/primitives/issues/2171
+ ownerDocument.removeEventListener("click", handleClickRef.current);
+ }
+ isPointerInsideReactTreeRef.current = false;
+ };
+ /**
+ * if this hook executes in a component that mounts via a `pointerdown` event, the event
+ * would bubble up to the document and trigger a `pointerDownOutside` event. We avoid
+ * this by delaying the event listener registration on the document.
+ * This is not React specific, but rather how the DOM works, ie:
+ * ```
+ * button.addEventListener('pointerdown', () => {
+ * console.log('I will log');
+ * document.addEventListener('pointerdown', () => {
+ * console.log('I will also log');
+ * })
+ * });
+ */
+ const timerId = window.setTimeout(() => {
+ ownerDocument.addEventListener("pointerdown", handlePointerDown);
+ }, 0);
+ return () => {
+ window.clearTimeout(timerId);
+ ownerDocument.removeEventListener("pointerdown", handlePointerDown);
+ ownerDocument.removeEventListener("click", handleClickRef.current);
+ };
+ }, [ownerDocument, handlePointerDownOutside]);
+
+ return {
+ // ensures we check React component tree (not just DOM tree)
+ onPointerDownCapture: () => (isPointerInsideReactTreeRef.current = true),
+ };
+}
+
+/**
+ * Listens for when focus happens outside a react subtree.
+ * Returns props to pass to the root (node) of the subtree we want to check.
+ */
+function useFocusOutside(
+ onFocusOutside?: (event: FocusOutsideEvent) => void,
+ ownerDocument: Document = globalThis?.document,
+) {
+ const handleFocusOutside = useCallbackRef(onFocusOutside) as EventListener;
+ const isFocusInsideReactTreeRef = React.useRef(false);
+
+ React.useEffect(() => {
+ const handleFocus = (event: FocusEvent) => {
+ if (event.target && !isFocusInsideReactTreeRef.current) {
+ const eventDetail = { originalEvent: event };
+ handleAndDispatchCustomEvent(
+ FOCUS_OUTSIDE,
+ handleFocusOutside,
+ eventDetail,
+ {
+ discrete: false,
+ },
+ );
+ }
+ };
+ ownerDocument.addEventListener("focusin", handleFocus);
+ return () => ownerDocument.removeEventListener("focusin", handleFocus);
+ }, [ownerDocument, handleFocusOutside]);
+
+ return {
+ onFocusCapture: () => (isFocusInsideReactTreeRef.current = true),
+ onBlurCapture: () => (isFocusInsideReactTreeRef.current = false),
+ };
+}
+
+function dispatchUpdate() {
+ const event = new CustomEvent(CONTEXT_UPDATE);
+ document.dispatchEvent(event);
+}
+
+function handleAndDispatchCustomEvent<
+ E extends CustomEvent,
+ OriginalEvent extends Event,
+>(
+ name: string,
+ handler: ((event: E) => void) | undefined,
+ detail:
+ & { originalEvent: OriginalEvent }
+ & (E extends CustomEvent ? D : never),
+ { discrete }: { discrete: boolean },
+) {
+ const target = detail.originalEvent.target;
+ const event = new CustomEvent(name, {
+ bubbles: false,
+ cancelable: true,
+ detail,
+ });
+ if (handler) {
+ target.addEventListener(name, handler as EventListener, { once: true });
+ }
+
+ if (discrete) {
+ dispatchDiscreteCustomEvent(target, event);
+ } else {
+ target.dispatchEvent(event);
+ }
+}
+
+const Root = DismissableLayer;
+const Branch = DismissableLayerBranch;
+
+export {
+ Branch,
+ DismissableLayer,
+ DismissableLayerBranch,
+ //
+ Root,
+};
+export type { DismissableLayerProps };
diff --git a/pkg/radix-ui-primitives/preact/dismissable-layer/mod.ts b/pkg/radix-ui-primitives/preact/dismissable-layer/mod.ts
new file mode 100644
index 0000000..1c16990
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/dismissable-layer/mod.ts
@@ -0,0 +1,8 @@
+export {
+ Branch,
+ DismissableLayer,
+ DismissableLayerBranch,
+ //
+ Root,
+} from "./DismissableLayer.tsx";
+export type { DismissableLayerProps } from "./DismissableLayer.tsx";
diff --git a/pkg/radix-ui-primitives/preact/dropdown-menu/DropdownMenu.tsx b/pkg/radix-ui-primitives/preact/dropdown-menu/DropdownMenu.tsx
new file mode 100644
index 0000000..4233d97
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/dropdown-menu/DropdownMenu.tsx
@@ -0,0 +1,712 @@
+import * as React from "preact/compat";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { composeRefs } from "../compose-refs/mod.ts";
+import { createContextScope } from "../context/mod.ts";
+import { useControllableState } from "../use-controllable-state/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+import * as MenuPrimitive from "../menu/mod.ts";
+import { createMenuScope } from "../menu/mod.ts";
+import { useId } from "../id/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+
+type Direction = "ltr" | "rtl";
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenu
+ * -----------------------------------------------------------------------------------------------*/
+
+const DROPDOWN_MENU_NAME = "DropdownMenu";
+
+type ScopedProps = P & { __scopeDropdownMenu?: Scope };
+const [createDropdownMenuContext, createDropdownMenuScope] = createContextScope(
+ DROPDOWN_MENU_NAME,
+ [createMenuScope],
+);
+const useMenuScope = createMenuScope();
+
+type DropdownMenuContextValue = {
+ triggerId: string;
+ triggerRef: React.RefObject;
+ contentId: string;
+ open: boolean;
+ onOpenChange(open: boolean): void;
+ onOpenToggle(): void;
+ modal: boolean;
+};
+
+const [DropdownMenuProvider, useDropdownMenuContext] =
+ createDropdownMenuContext(DROPDOWN_MENU_NAME);
+
+interface DropdownMenuProps {
+ children?: React.ComponentChildren;
+ dir?: Direction;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?(open: boolean): void;
+ modal?: boolean;
+}
+
+const DropdownMenu: React.FC = (
+ props: ScopedProps,
+) => {
+ const {
+ __scopeDropdownMenu,
+ children,
+ dir,
+ open: openProp,
+ defaultOpen,
+ onOpenChange,
+ modal = true,
+ } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ const triggerRef = React.useRef(null);
+ const [open = false, setOpen] = useControllableState({
+ prop: openProp,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+
+ return (
+ setOpen((prevOpen) => !prevOpen),
+ [
+ setOpen,
+ ],
+ )}
+ modal={modal}
+ >
+
+ {children}
+
+
+ );
+};
+
+DropdownMenu.displayName = DROPDOWN_MENU_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+const TRIGGER_NAME = "DropdownMenuTrigger";
+
+type DropdownMenuTriggerElement = React.ElementRef;
+type PrimitiveButtonProps = Radix.ComponentPropsWithoutRef<
+ typeof Primitive.button
+>;
+interface DropdownMenuTriggerProps extends PrimitiveButtonProps {}
+
+const DropdownMenuTrigger = React.forwardRef<
+ DropdownMenuTriggerElement,
+ DropdownMenuTriggerProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, disabled = false, ...triggerProps } = props;
+ const context = useDropdownMenuContext(TRIGGER_NAME, __scopeDropdownMenu);
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ {
+ // only call handler if it's the left button (mousedown gets triggered by all mouse buttons)
+ // but not when the control key is pressed (avoiding MacOS right click)
+ if (!disabled && event.button === 0 && event.ctrlKey === false) {
+ context.onOpenToggle();
+ // prevent trigger focusing when opening
+ // this allows the content to be given focus without competition
+ if (!context.open) event.preventDefault();
+ }
+ })}
+ onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
+ if (disabled) return;
+ if (["Enter", " "].includes(event.key)) context.onOpenToggle();
+ if (event.key === "ArrowDown") context.onOpenChange(true);
+ // prevent keydown from scrolling window / first focused item to execute
+ // that keydown (inadvertently closing the menu)
+ if (["Enter", " ", "ArrowDown"].includes(event.key)) {
+ event.preventDefault();
+ }
+ })}
+ />
+
+ );
+ },
+);
+
+DropdownMenuTrigger.displayName = TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuPortal
+ * -----------------------------------------------------------------------------------------------*/
+
+const PORTAL_NAME = "DropdownMenuPortal";
+
+type MenuPortalProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Portal
+>;
+interface DropdownMenuPortalProps extends MenuPortalProps {}
+
+const DropdownMenuPortal: React.FC = (
+ props: ScopedProps,
+) => {
+ const { __scopeDropdownMenu, ...portalProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return ;
+};
+
+DropdownMenuPortal.displayName = PORTAL_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuContent
+ * -----------------------------------------------------------------------------------------------*/
+
+const CONTENT_NAME = "DropdownMenuContent";
+
+type DropdownMenuContentElement = React.ElementRef<
+ typeof MenuPrimitive.Content
+>;
+type MenuContentProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Content
+>;
+interface DropdownMenuContentProps
+ extends Omit {}
+
+const DropdownMenuContent = React.forwardRef<
+ DropdownMenuContentElement,
+ DropdownMenuContentProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...contentProps } = props;
+ const context = useDropdownMenuContext(CONTENT_NAME, __scopeDropdownMenu);
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ const hasInteractedOutsideRef = React.useRef(false);
+
+ return (
+ {
+ if (!hasInteractedOutsideRef.current) {
+ context.triggerRef.current
+ ?.focus();
+ }
+ hasInteractedOutsideRef.current = false;
+ // Always prevent auto focus because we either focus manually or want user agent focus
+ event.preventDefault();
+ },
+ )}
+ onInteractOutside={composeEventHandlers(
+ props.onInteractOutside,
+ (event) => {
+ const originalEvent = event.detail.originalEvent as PointerEvent;
+ const ctrlLeftClick = originalEvent.button === 0 &&
+ originalEvent.ctrlKey === true;
+ const isRightClick = originalEvent.button === 2 || ctrlLeftClick;
+ if (!context.modal || isRightClick) {
+ hasInteractedOutsideRef
+ .current = true;
+ }
+ },
+ )}
+ style={{
+ ...props.style,
+ // re-namespace exposed content custom properties
+ ...{
+ "--radix-dropdown-menu-content-transform-origin":
+ "var(--radix-popper-transform-origin)",
+ "--radix-dropdown-menu-content-available-width":
+ "var(--radix-popper-available-width)",
+ "--radix-dropdown-menu-content-available-height":
+ "var(--radix-popper-available-height)",
+ "--radix-dropdown-menu-trigger-width":
+ "var(--radix-popper-anchor-width)",
+ "--radix-dropdown-menu-trigger-height":
+ "var(--radix-popper-anchor-height)",
+ },
+ }}
+ />
+ );
+ },
+);
+
+DropdownMenuContent.displayName = CONTENT_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuGroup
+ * -----------------------------------------------------------------------------------------------*/
+
+const GROUP_NAME = "DropdownMenuGroup";
+
+type DropdownMenuGroupElement = React.ElementRef;
+type MenuGroupProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Group
+>;
+interface DropdownMenuGroupProps extends MenuGroupProps {}
+
+const DropdownMenuGroup = React.forwardRef<
+ DropdownMenuGroupElement,
+ DropdownMenuGroupProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...groupProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+ },
+);
+
+DropdownMenuGroup.displayName = GROUP_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuLabel
+ * -----------------------------------------------------------------------------------------------*/
+
+const LABEL_NAME = "DropdownMenuLabel";
+
+type DropdownMenuLabelElement = React.ElementRef;
+type MenuLabelProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Label
+>;
+interface DropdownMenuLabelProps extends MenuLabelProps {}
+
+const DropdownMenuLabel = React.forwardRef<
+ DropdownMenuLabelElement,
+ DropdownMenuLabelProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...labelProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+ },
+);
+
+DropdownMenuLabel.displayName = LABEL_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuItem
+ * -----------------------------------------------------------------------------------------------*/
+
+const ITEM_NAME = "DropdownMenuItem";
+
+type DropdownMenuItemElement = React.ElementRef;
+type MenuItemProps = Radix.ComponentPropsWithoutRef;
+interface DropdownMenuItemProps extends MenuItemProps {}
+
+const DropdownMenuItem = React.forwardRef<
+ DropdownMenuItemElement,
+ DropdownMenuItemProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...itemProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+ },
+);
+
+DropdownMenuItem.displayName = ITEM_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuCheckboxItem
+ * -----------------------------------------------------------------------------------------------*/
+
+const CHECKBOX_ITEM_NAME = "DropdownMenuCheckboxItem";
+
+type DropdownMenuCheckboxItemElement = React.ElementRef<
+ typeof MenuPrimitive.CheckboxItem
+>;
+type MenuCheckboxItemProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.CheckboxItem
+>;
+interface DropdownMenuCheckboxItemProps extends MenuCheckboxItemProps {}
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ DropdownMenuCheckboxItemElement,
+ DropdownMenuCheckboxItemProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...checkboxItemProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+});
+
+DropdownMenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuRadioGroup
+ * -----------------------------------------------------------------------------------------------*/
+
+const RADIO_GROUP_NAME = "DropdownMenuRadioGroup";
+
+type DropdownMenuRadioGroupElement = React.ElementRef<
+ typeof MenuPrimitive.RadioGroup
+>;
+type MenuRadioGroupProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.RadioGroup
+>;
+interface DropdownMenuRadioGroupProps extends MenuRadioGroupProps {}
+
+const DropdownMenuRadioGroup = React.forwardRef<
+ DropdownMenuRadioGroupElement,
+ DropdownMenuRadioGroupProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...radioGroupProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+});
+
+DropdownMenuRadioGroup.displayName = RADIO_GROUP_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuRadioItem
+ * -----------------------------------------------------------------------------------------------*/
+
+const RADIO_ITEM_NAME = "DropdownMenuRadioItem";
+
+type DropdownMenuRadioItemElement = React.ElementRef<
+ typeof MenuPrimitive.RadioItem
+>;
+type MenuRadioItemProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.RadioItem
+>;
+interface DropdownMenuRadioItemProps extends MenuRadioItemProps {}
+
+const DropdownMenuRadioItem = React.forwardRef<
+ DropdownMenuRadioItemElement,
+ DropdownMenuRadioItemProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...radioItemProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+});
+
+DropdownMenuRadioItem.displayName = RADIO_ITEM_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuItemIndicator
+ * -----------------------------------------------------------------------------------------------*/
+
+const INDICATOR_NAME = "DropdownMenuItemIndicator";
+
+type DropdownMenuItemIndicatorElement = React.ElementRef<
+ typeof MenuPrimitive.ItemIndicator
+>;
+type MenuItemIndicatorProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.ItemIndicator
+>;
+interface DropdownMenuItemIndicatorProps extends MenuItemIndicatorProps {}
+
+const DropdownMenuItemIndicator = React.forwardRef<
+ DropdownMenuItemIndicatorElement,
+ DropdownMenuItemIndicatorProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...itemIndicatorProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+});
+
+DropdownMenuItemIndicator.displayName = INDICATOR_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuSeparator
+ * -----------------------------------------------------------------------------------------------*/
+
+const SEPARATOR_NAME = "DropdownMenuSeparator";
+
+type DropdownMenuSeparatorElement = React.ElementRef<
+ typeof MenuPrimitive.Separator
+>;
+type MenuSeparatorProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Separator
+>;
+interface DropdownMenuSeparatorProps extends MenuSeparatorProps {}
+
+const DropdownMenuSeparator = React.forwardRef<
+ DropdownMenuSeparatorElement,
+ DropdownMenuSeparatorProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...separatorProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+});
+
+DropdownMenuSeparator.displayName = SEPARATOR_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuArrow
+ * -----------------------------------------------------------------------------------------------*/
+
+const ARROW_NAME = "DropdownMenuArrow";
+
+type DropdownMenuArrowElement = React.ElementRef;
+type MenuArrowProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.Arrow
+>;
+interface DropdownMenuArrowProps extends MenuArrowProps {}
+
+const DropdownMenuArrow = React.forwardRef<
+ DropdownMenuArrowElement,
+ DropdownMenuArrowProps
+>(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...arrowProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+ },
+);
+
+DropdownMenuArrow.displayName = ARROW_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuSub
+ * -----------------------------------------------------------------------------------------------*/
+
+interface DropdownMenuSubProps {
+ children?: React.ComponentChildren;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?(open: boolean): void;
+}
+
+const DropdownMenuSub: React.FC = (
+ props: ScopedProps,
+) => {
+ const {
+ __scopeDropdownMenu,
+ children,
+ open: openProp,
+ onOpenChange,
+ defaultOpen,
+ } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ const [open = false, setOpen] = useControllableState({
+ prop: openProp,
+ defaultProp: defaultOpen,
+ onChange: onOpenChange,
+ });
+
+ return (
+
+ {children}
+
+ );
+};
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuSubTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+const SUB_TRIGGER_NAME = "DropdownMenuSubTrigger";
+
+type DropdownMenuSubTriggerElement = React.ElementRef<
+ typeof MenuPrimitive.SubTrigger
+>;
+type MenuSubTriggerProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.SubTrigger
+>;
+interface DropdownMenuSubTriggerProps extends MenuSubTriggerProps {}
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ DropdownMenuSubTriggerElement,
+ DropdownMenuSubTriggerProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...subTriggerProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+ return (
+
+ );
+});
+
+DropdownMenuSubTrigger.displayName = SUB_TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * DropdownMenuSubContent
+ * -----------------------------------------------------------------------------------------------*/
+
+const SUB_CONTENT_NAME = "DropdownMenuSubContent";
+
+type DropdownMenuSubContentElement = React.ElementRef<
+ typeof MenuPrimitive.Content
+>;
+type MenuSubContentProps = Radix.ComponentPropsWithoutRef<
+ typeof MenuPrimitive.SubContent
+>;
+interface DropdownMenuSubContentProps extends MenuSubContentProps {}
+
+const DropdownMenuSubContent = React.forwardRef<
+ DropdownMenuSubContentElement,
+ DropdownMenuSubContentProps
+>((props: ScopedProps, forwardedRef) => {
+ const { __scopeDropdownMenu, ...subContentProps } = props;
+ const menuScope = useMenuScope(__scopeDropdownMenu);
+
+ return (
+
+ );
+});
+
+DropdownMenuSubContent.displayName = SUB_CONTENT_NAME;
+
+/* -----------------------------------------------------------------------------------------------*/
+
+const Root = DropdownMenu;
+const Trigger = DropdownMenuTrigger;
+const Portal = DropdownMenuPortal;
+const Content = DropdownMenuContent;
+const Group = DropdownMenuGroup;
+const Label = DropdownMenuLabel;
+const Item = DropdownMenuItem;
+const CheckboxItem = DropdownMenuCheckboxItem;
+const RadioGroup = DropdownMenuRadioGroup;
+const RadioItem = DropdownMenuRadioItem;
+const ItemIndicator = DropdownMenuItemIndicator;
+const Separator = DropdownMenuSeparator;
+const Arrow = DropdownMenuArrow;
+const Sub = DropdownMenuSub;
+const SubTrigger = DropdownMenuSubTrigger;
+const SubContent = DropdownMenuSubContent;
+
+export {
+ Arrow,
+ CheckboxItem,
+ Content,
+ createDropdownMenuScope,
+ //
+ DropdownMenu,
+ DropdownMenuArrow,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuItemIndicator,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+ Group,
+ Item,
+ ItemIndicator,
+ Label,
+ Portal,
+ RadioGroup,
+ RadioItem,
+ //
+ Root,
+ Separator,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger,
+};
+export type {
+ DropdownMenuArrowProps,
+ DropdownMenuCheckboxItemProps,
+ DropdownMenuContentProps,
+ DropdownMenuGroupProps,
+ DropdownMenuItemIndicatorProps,
+ DropdownMenuItemProps,
+ DropdownMenuLabelProps,
+ DropdownMenuPortalProps,
+ DropdownMenuProps,
+ DropdownMenuRadioGroupProps,
+ DropdownMenuRadioItemProps,
+ DropdownMenuSeparatorProps,
+ DropdownMenuSubContentProps,
+ DropdownMenuSubProps,
+ DropdownMenuSubTriggerProps,
+ DropdownMenuTriggerProps,
+};
diff --git a/pkg/radix-ui-primitives/preact/dropdown-menu/mod.ts b/pkg/radix-ui-primitives/preact/dropdown-menu/mod.ts
new file mode 100644
index 0000000..b89a240
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/dropdown-menu/mod.ts
@@ -0,0 +1,55 @@
+export {
+ Arrow,
+ CheckboxItem,
+ Content,
+ createDropdownMenuScope,
+ //
+ DropdownMenu,
+ DropdownMenuArrow,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuItemIndicator,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+ Group,
+ Item,
+ ItemIndicator,
+ Label,
+ Portal,
+ RadioGroup,
+ RadioItem,
+ //
+ Root,
+ Separator,
+ Sub,
+ SubContent,
+ SubTrigger,
+ Trigger,
+} from "./DropdownMenu.tsx";
+export type {
+ DropdownMenuArrowProps,
+ DropdownMenuCheckboxItemProps,
+ DropdownMenuContentProps,
+ DropdownMenuGroupProps,
+ DropdownMenuItemIndicatorProps,
+ DropdownMenuItemProps,
+ DropdownMenuLabelProps,
+ DropdownMenuPortalProps,
+ DropdownMenuProps,
+ DropdownMenuRadioGroupProps,
+ DropdownMenuRadioItemProps,
+ DropdownMenuSeparatorProps,
+ DropdownMenuSubContentProps,
+ DropdownMenuSubProps,
+ DropdownMenuSubTriggerProps,
+ DropdownMenuTriggerProps,
+} from "./DropdownMenu.tsx";
diff --git a/pkg/radix-ui-primitives/preact/focus-guards/FocusGuards.tsx b/pkg/radix-ui-primitives/preact/focus-guards/FocusGuards.tsx
new file mode 100644
index 0000000..1f202cf
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/focus-guards/FocusGuards.tsx
@@ -0,0 +1,56 @@
+import * as React from "preact/compat";
+
+/** Number of components which have requested interest to have focus guards */
+let count = 0;
+
+function FocusGuards(props: any) {
+ useFocusGuards();
+ return props.children;
+}
+
+/**
+ * Injects a pair of focus guards at the edges of the whole DOM tree
+ * to ensure `focusin` & `focusout` events can be caught consistently.
+ */
+function useFocusGuards() {
+ React.useEffect(() => {
+ const edgeGuards = document.querySelectorAll("[data-radix-focus-guard]");
+ document.body.insertAdjacentElement(
+ "afterbegin",
+ edgeGuards[0] ?? createFocusGuard(),
+ );
+ document.body.insertAdjacentElement(
+ "beforeend",
+ edgeGuards[1] ?? createFocusGuard(),
+ );
+ count++;
+
+ return () => {
+ if (count === 1) {
+ document.querySelectorAll("[data-radix-focus-guard]").forEach((node) =>
+ node.remove()
+ );
+ }
+ count--;
+ };
+ }, []);
+}
+
+function createFocusGuard() {
+ const element = document.createElement("span");
+ element.setAttribute("data-radix-focus-guard", "");
+ element.tabIndex = 0;
+ element.style.cssText =
+ "outline: none; opacity: 0; position: fixed; pointer-events: none";
+ return element;
+}
+
+const Root = FocusGuards;
+
+export {
+ FocusGuards,
+ //
+ Root,
+ //
+ useFocusGuards,
+};
diff --git a/pkg/radix-ui-primitives/preact/focus-guards/mod.ts b/pkg/radix-ui-primitives/preact/focus-guards/mod.ts
new file mode 100644
index 0000000..dc9311f
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/focus-guards/mod.ts
@@ -0,0 +1,7 @@
+export {
+ FocusGuards,
+ //
+ Root,
+ //
+ useFocusGuards,
+} from "./FocusGuards.tsx";
diff --git a/pkg/radix-ui-primitives/preact/focus-scope/FocusScope.tsx b/pkg/radix-ui-primitives/preact/focus-scope/FocusScope.tsx
new file mode 100644
index 0000000..db69cfd
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/focus-scope/FocusScope.tsx
@@ -0,0 +1,401 @@
+import * as React from "preact/compat";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+import { useCallbackRef } from "../use-callback-ref/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+
+const AUTOFOCUS_ON_MOUNT = "focusScope.autoFocusOnMount";
+const AUTOFOCUS_ON_UNMOUNT = "focusScope.autoFocusOnUnmount";
+const EVENT_OPTIONS = { bubbles: false, cancelable: true };
+
+type FocusableTarget = HTMLElement | { focus(): void };
+
+/* -------------------------------------------------------------------------------------------------
+ * FocusScope
+ * -----------------------------------------------------------------------------------------------*/
+
+const FOCUS_SCOPE_NAME = "FocusScope";
+
+type FocusScopeElement = React.ElementRef;
+type PrimitiveDivProps = Radix.ComponentPropsWithoutRef;
+interface FocusScopeProps extends PrimitiveDivProps {
+ /**
+ * When `true`, tabbing from last item will focus first tabbable
+ * and shift+tab from first item will focus last tababble.
+ * @defaultValue false
+ */
+ loop?: boolean;
+
+ /**
+ * When `true`, focus cannot escape the focus scope via keyboard,
+ * pointer, or a programmatic focus.
+ * @defaultValue false
+ */
+ trapped?: boolean;
+
+ /**
+ * Event handler called when auto-focusing on mount.
+ * Can be prevented.
+ */
+ onMountAutoFocus?: (event: Event) => void;
+
+ /**
+ * Event handler called when auto-focusing on unmount.
+ * Can be prevented.
+ */
+ onUnmountAutoFocus?: (event: Event) => void;
+}
+
+const FocusScope = React.forwardRef(
+ (props, forwardedRef) => {
+ const {
+ loop = false,
+ trapped = false,
+ onMountAutoFocus: onMountAutoFocusProp,
+ onUnmountAutoFocus: onUnmountAutoFocusProp,
+ ...scopeProps
+ } = props;
+ const [container, setContainer] = React.useState(
+ null,
+ );
+ const onMountAutoFocus = useCallbackRef(onMountAutoFocusProp);
+ const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp);
+ const lastFocusedElementRef = React.useRef(null);
+ const composedRefs = useComposedRefs(
+ forwardedRef,
+ (node) => setContainer(node),
+ );
+
+ const focusScope = React.useRef({
+ paused: false,
+ pause() {
+ this.paused = true;
+ },
+ resume() {
+ this.paused = false;
+ },
+ }).current;
+
+ // Takes care of trapping focus if focus is moved outside programmatically for example
+ React.useEffect(() => {
+ if (trapped) {
+ function handleFocusIn(event: FocusEvent) {
+ if (focusScope.paused || !container) {
+ return;
+ }
+ const target = event.target as HTMLElement | null;
+ if (container.contains(target)) {
+ lastFocusedElementRef.current = target;
+ } else {
+ focus(lastFocusedElementRef.current, { select: true });
+ }
+ }
+
+ function handleFocusOut(event: FocusEvent) {
+ if (focusScope.paused || !container) return;
+ const relatedTarget = event.relatedTarget as HTMLElement | null;
+
+ // A `focusout` event with a `null` `relatedTarget` will happen in at least two cases:
+ //
+ // 1. When the user switches app/tabs/windows/the browser itself loses focus.
+ // 2. In Google Chrome, when the focused element is removed from the DOM.
+ //
+ // We let the browser do its thing here because:
+ //
+ // 1. The browser already keeps a memory of what's focused for when the page gets refocused.
+ // 2. In Google Chrome, if we try to focus the deleted focused element (as per below), it
+ // throws the CPU to 100%, so we avoid doing anything for this reason here too.
+ if (relatedTarget === null) return;
+
+ // If the focus has moved to an actual legitimate element (`relatedTarget !== null`)
+ // that is outside the container, we move focus to the last valid focused element inside.
+ if (!container.contains(relatedTarget)) {
+ focus(lastFocusedElementRef.current, { select: true });
+ }
+ }
+
+ // When the focused element gets removed from the DOM, browsers move focus
+ // back to the document.body. In this case, we move focus to the container
+ // to keep focus trapped correctly.
+ function handleMutations(mutations: MutationRecord[]) {
+ const focusedElement = document.activeElement as HTMLElement | null;
+ if (focusedElement !== document.body) return;
+ for (const mutation of mutations) {
+ if (mutation.removedNodes.length > 0) focus(container);
+ }
+ }
+
+ document.addEventListener("focusin", handleFocusIn);
+ document.addEventListener("focusout", handleFocusOut);
+ const mutationObserver = new MutationObserver(handleMutations);
+ if (container) {
+ mutationObserver.observe(container, {
+ childList: true,
+ subtree: true,
+ });
+ }
+
+ return () => {
+ document.removeEventListener("focusin", handleFocusIn);
+ document.removeEventListener("focusout", handleFocusOut);
+ mutationObserver.disconnect();
+ };
+ }
+ }, [trapped, container, focusScope.paused]);
+
+ React.useEffect(() => {
+ if (container) {
+ focusScopesStack.add(focusScope);
+ const previouslyFocusedElement = document.activeElement as
+ | HTMLElement
+ | null;
+ const hasFocusedCandidate = container.contains(
+ previouslyFocusedElement,
+ );
+
+ if (!hasFocusedCandidate) {
+ const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS);
+ container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
+ container.dispatchEvent(mountEvent);
+ if (!mountEvent.defaultPrevented) {
+ focusFirst(removeLinks(getTabbableCandidates(container)), {
+ select: true,
+ });
+ if (document.activeElement === previouslyFocusedElement) {
+ focus(container);
+ }
+ }
+ }
+
+ return () => {
+ container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
+
+ // We hit a react bug (fixed in v17) with focusing in unmount.
+ // We need to delay the focus a little to get around it for now.
+ // See: https://github.com/facebook/react/issues/17894
+ setTimeout(() => {
+ const unmountEvent = new CustomEvent(
+ AUTOFOCUS_ON_UNMOUNT,
+ EVENT_OPTIONS,
+ );
+ container.addEventListener(
+ AUTOFOCUS_ON_UNMOUNT,
+ onUnmountAutoFocus,
+ );
+ container.dispatchEvent(unmountEvent);
+ if (!unmountEvent.defaultPrevented) {
+ focus(previouslyFocusedElement ?? document.body, {
+ select: true,
+ });
+ }
+ // we need to remove the listener after we `dispatchEvent`
+ container.removeEventListener(
+ AUTOFOCUS_ON_UNMOUNT,
+ onUnmountAutoFocus,
+ );
+
+ focusScopesStack.remove(focusScope);
+ }, 0);
+ };
+ }
+ }, [container, onMountAutoFocus, onUnmountAutoFocus, focusScope]);
+
+ // Takes care of looping focus (when tabbing whilst at the edges)
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (!loop && !trapped) return;
+ if (focusScope.paused) return;
+
+ const isTabKey = event.key === "Tab" && !event.altKey &&
+ !event.ctrlKey && !event.metaKey;
+ const focusedElement = document.activeElement as HTMLElement | null;
+
+ if (isTabKey && focusedElement) {
+ const container = event.currentTarget as HTMLElement;
+ const [first, last] = getTabbableEdges(container);
+ const hasTabbableElementsInside = first && last;
+
+ // we can only wrap focus if we have tabbable edges
+ if (!hasTabbableElementsInside) {
+ if (focusedElement === container) event.preventDefault();
+ } else {
+ if (!event.shiftKey && focusedElement === last) {
+ event.preventDefault();
+ if (loop) focus(first, { select: true });
+ } else if (event.shiftKey && focusedElement === first) {
+ event.preventDefault();
+ if (loop) focus(last, { select: true });
+ }
+ }
+ }
+ },
+ [loop, trapped, focusScope.paused],
+ );
+
+ return (
+
+ );
+ },
+);
+
+FocusScope.displayName = FOCUS_SCOPE_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * Utils
+ * -----------------------------------------------------------------------------------------------*/
+
+/**
+ * Attempts focusing the first element in a list of candidates.
+ * Stops when focus has actually moved.
+ */
+function focusFirst(candidates: HTMLElement[], { select = false } = {}) {
+ const previouslyFocusedElement = document.activeElement;
+ for (const candidate of candidates) {
+ focus(candidate, { select });
+ if (document.activeElement !== previouslyFocusedElement) return;
+ }
+}
+
+/**
+ * Returns the first and last tabbable elements inside a container.
+ */
+function getTabbableEdges(container: HTMLElement) {
+ const candidates = getTabbableCandidates(container);
+ const first = findVisible(candidates, container);
+ const last = findVisible(candidates.reverse(), container);
+ return [first, last] as const;
+}
+
+/**
+ * Returns a list of potential tabbable candidates.
+ *
+ * NOTE: This is only a close approximation. For example it doesn't take into account cases like when
+ * elements are not visible. This cannot be worked out easily by just reading a property, but rather
+ * necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
+ *
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
+ * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
+ */
+function getTabbableCandidates(container: HTMLElement) {
+ const nodes: HTMLElement[] = [];
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node: any) => {
+ const isHiddenInput = node.tagName === "INPUT" && node.type === "hidden";
+ if (node.disabled || node.hidden || isHiddenInput) {
+ return NodeFilter.FILTER_SKIP;
+ }
+ // `.tabIndex` is not the same as the `tabindex` attribute. It works on the
+ // runtime's understanding of tabbability, so this automatically accounts
+ // for any kind of element that could be tabbed to.
+ return node.tabIndex >= 0
+ ? NodeFilter.FILTER_ACCEPT
+ : NodeFilter.FILTER_SKIP;
+ },
+ });
+ while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
+ // we do not take into account the order of nodes with positive `tabIndex` as it
+ // hinders accessibility to have tab order different from visual order.
+ return nodes;
+}
+
+/**
+ * Returns the first visible element in a list.
+ * NOTE: Only checks visibility up to the `container`.
+ */
+function findVisible(elements: HTMLElement[], container: HTMLElement) {
+ for (const element of elements) {
+ // we stop checking if it's hidden at the `container` level (excluding)
+ if (!isHidden(element, { upTo: container })) return element;
+ }
+}
+
+function isHidden(node: HTMLElement, { upTo }: { upTo?: HTMLElement }) {
+ if (getComputedStyle(node).visibility === "hidden") return true;
+ while (node) {
+ // we stop at `upTo` (excluding it)
+ if (upTo !== undefined && node === upTo) return false;
+ if (getComputedStyle(node).display === "none") return true;
+ node = node.parentElement as HTMLElement;
+ }
+ return false;
+}
+
+function isSelectableInput(
+ element: any,
+): element is FocusableTarget & { select: () => void } {
+ return element instanceof HTMLInputElement && "select" in element;
+}
+
+function focus(element?: FocusableTarget | null, { select = false } = {}) {
+ // only focus if that element is focusable
+ if (element && element.focus) {
+ const previouslyFocusedElement = document.activeElement;
+ // NOTE: we prevent scrolling on focus, to minimize jarring transitions for users
+ element.focus({ preventScroll: true });
+ // only select if its not the same element, it supports selection and we need to select
+ if (
+ element !== previouslyFocusedElement && isSelectableInput(element) &&
+ select
+ ) {
+ element.select();
+ }
+ }
+}
+
+/* -------------------------------------------------------------------------------------------------
+ * FocusScope stack
+ * -----------------------------------------------------------------------------------------------*/
+
+type FocusScopeAPI = { paused: boolean; pause(): void; resume(): void };
+const focusScopesStack = createFocusScopesStack();
+
+function createFocusScopesStack() {
+ /** A stack of focus scopes, with the active one at the top */
+ let stack: FocusScopeAPI[] = [];
+
+ return {
+ add(focusScope: FocusScopeAPI) {
+ // pause the currently active focus scope (at the top of the stack)
+ const activeFocusScope = stack[0];
+ if (focusScope !== activeFocusScope) {
+ activeFocusScope?.pause();
+ }
+ // remove in case it already exists (because we'll re-add it at the top of the stack)
+ stack = arrayRemove(stack, focusScope);
+ stack.unshift(focusScope);
+ },
+
+ remove(focusScope: FocusScopeAPI) {
+ stack = arrayRemove(stack, focusScope);
+ stack[0]?.resume();
+ },
+ };
+}
+
+function arrayRemove(array: T[], item: T) {
+ const updatedArray = [...array];
+ const index = updatedArray.indexOf(item);
+ if (index !== -1) {
+ updatedArray.splice(index, 1);
+ }
+ return updatedArray;
+}
+
+function removeLinks(items: HTMLElement[]) {
+ return items.filter((item) => item.tagName !== "A");
+}
+
+const Root = FocusScope;
+
+export {
+ FocusScope,
+ //
+ Root,
+};
+export type { FocusScopeProps };
diff --git a/pkg/radix-ui-primitives/preact/focus-scope/mod.ts b/pkg/radix-ui-primitives/preact/focus-scope/mod.ts
new file mode 100644
index 0000000..7b63992
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/focus-scope/mod.ts
@@ -0,0 +1,6 @@
+export {
+ FocusScope,
+ //
+ Root,
+} from "./FocusScope.tsx";
+export type { FocusScopeProps } from "./FocusScope.tsx";
diff --git a/pkg/radix-ui-primitives/preact/form/Form.tsx b/pkg/radix-ui-primitives/preact/form/Form.tsx
new file mode 100644
index 0000000..e66a8fd
--- /dev/null
+++ b/pkg/radix-ui-primitives/preact/form/Form.tsx
@@ -0,0 +1,905 @@
+import * as React from "preact/compat";
+import { composeEventHandlers } from "../../core/primitive/mod.ts";
+import { useComposedRefs } from "../compose-refs/mod.ts";
+import { createContextScope } from "../context/mod.ts";
+import { useId } from "../id/mod.ts";
+import { Label as LabelPrimitive } from "../label/mod.ts";
+import { Primitive } from "../primitive/mod.ts";
+
+import type * as Radix from "../primitive/mod.ts";
+import type { Scope } from "../context/mod.ts";
+
+type ScopedProps = P & { __scopeForm?: Scope };
+const [createFormContext, createFormScope] = createContextScope("Form");
+
+/* -------------------------------------------------------------------------------------------------
+ * Form
+ * -----------------------------------------------------------------------------------------------*/
+
+const FORM_NAME = "Form";
+
+type ValidityMap = { [fieldName: string]: ValidityState | undefined };
+type CustomMatcherEntriesMap = { [fieldName: string]: CustomMatcherEntry[] };
+type CustomErrorsMap = { [fieldName: string]: Record };
+
+type ValidationContextValue = {
+ getFieldValidity(fieldName: string): ValidityState | undefined;
+ onFieldValidityChange(fieldName: string, validity: ValidityState): void;
+
+ getFieldCustomMatcherEntries(fieldName: string): CustomMatcherEntry[];
+ onFieldCustomMatcherEntryAdd(
+ fieldName: string,
+ matcherEntry: CustomMatcherEntry,
+ ): void;
+ onFieldCustomMatcherEntryRemove(
+ fieldName: string,
+ matcherEntryId: string,
+ ): void;
+
+ getFieldCustomErrors(fieldName: string): Record;
+ onFieldCustomErrorsChange(
+ fieldName: string,
+ errors: Record,
+ ): void;
+
+ onFieldValiditionClear(fieldName: string): void;
+};
+const [ValidationProvider, useValidationContext] = createFormContext<
+ ValidationContextValue
+>(FORM_NAME);
+
+type MessageIdsMap = { [fieldName: string]: Set };
+
+type AriaDescriptionContextValue = {
+ onFieldMessageIdAdd(fieldName: string, id: string): void;
+ onFieldMessageIdRemove(fieldName: string, id: string): void;
+ getFieldDescription(fieldName: string): string | undefined;
+};
+const [AriaDescriptionProvider, useAriaDescriptionContext] = createFormContext<
+ AriaDescriptionContextValue
+>(FORM_NAME);
+
+type FormElement = React.ElementRef;
+type PrimitiveFormProps = Radix.ComponentPropsWithoutRef;
+interface FormProps extends PrimitiveFormProps {
+ onClearServerErrors?(): void;
+}
+
+const Form = React.forwardRef(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeForm, onClearServerErrors = () => {}, ...rootProps } = props;
+ const formRef = React.useRef(null);
+ const composedFormRef = useComposedRefs(forwardedRef, formRef);
+
+ // native validity per field
+ const [validityMap, setValidityMap] = React.useState({});
+ const getFieldValidity: ValidationContextValue["getFieldValidity"] = React
+ .useCallback(
+ (fieldName) => validityMap[fieldName],
+ [validityMap],
+ );
+ const handleFieldValidityChange:
+ ValidationContextValue["onFieldValidityChange"] = React.useCallback(
+ (fieldName, validity) =>
+ setValidityMap((prevValidityMap) => ({
+ ...prevValidityMap,
+ [fieldName]: { ...(prevValidityMap[fieldName] ?? {}), ...validity },
+ })),
+ [],
+ );
+ const handleFieldValiditionClear:
+ ValidationContextValue["onFieldValiditionClear"] = React.useCallback(
+ (fieldName) => {
+ setValidityMap((prevValidityMap) => ({
+ ...prevValidityMap,
+ [fieldName]: undefined,
+ }));
+ setCustomErrorsMap((prevCustomErrorsMap) => ({
+ ...prevCustomErrorsMap,
+ [fieldName]: {},
+ }));
+ },
+ [],
+ );
+
+ // custom matcher entries per field
+ const [customMatcherEntriesMap, setCustomMatcherEntriesMap] = React
+ .useState({});
+ const getFieldCustomMatcherEntries:
+ ValidationContextValue["getFieldCustomMatcherEntries"] = React
+ .useCallback(
+ (fieldName) => customMatcherEntriesMap[fieldName] ?? [],
+ [customMatcherEntriesMap],
+ );
+ const handleFieldCustomMatcherAdd:
+ ValidationContextValue["onFieldCustomMatcherEntryAdd"] = React
+ .useCallback((fieldName, matcherEntry) => {
+ setCustomMatcherEntriesMap((prevCustomMatcherEntriesMap) => ({
+ ...prevCustomMatcherEntriesMap,
+ [fieldName]: [
+ ...(prevCustomMatcherEntriesMap[fieldName] ?? []),
+ matcherEntry,
+ ],
+ }));
+ }, []);
+ const handleFieldCustomMatcherRemove:
+ ValidationContextValue["onFieldCustomMatcherEntryRemove"] = React
+ .useCallback((fieldName, matcherEntryId) => {
+ setCustomMatcherEntriesMap((prevCustomMatcherEntriesMap) => ({
+ ...prevCustomMatcherEntriesMap,
+ [fieldName]: (prevCustomMatcherEntriesMap[fieldName] ?? []).filter(
+ (matcherEntry) => matcherEntry.id !== matcherEntryId,
+ ),
+ }));
+ }, []);
+
+ // custom errors per field
+ const [customErrorsMap, setCustomErrorsMap] = React.useState<
+ CustomErrorsMap
+ >({});
+ const getFieldCustomErrors: ValidationContextValue["getFieldCustomErrors"] =
+ React.useCallback(
+ (fieldName) => customErrorsMap[fieldName] ?? {},
+ [customErrorsMap],
+ );
+ const handleFieldCustomErrorsChange:
+ ValidationContextValue["onFieldCustomErrorsChange"] = React
+ .useCallback(
+ (fieldName, customErrors) => {
+ setCustomErrorsMap((prevCustomErrorsMap) => ({
+ ...prevCustomErrorsMap,
+ [fieldName]: {
+ ...(prevCustomErrorsMap[fieldName] ?? {}),
+ ...customErrors,
+ },
+ }));
+ },
+ [],
+ );
+
+ // messageIds per field
+ const [messageIdsMap, setMessageIdsMap] = React.useState<
+ MessageIdsMap
+ >({});
+ const handleFieldMessageIdAdd:
+ AriaDescriptionContextValue["onFieldMessageIdAdd"] = React
+ .useCallback(
+ (fieldName, id) => {
+ setMessageIdsMap((prevMessageIdsMap) => {
+ const fieldDescriptionIds = new Set(prevMessageIdsMap[fieldName])
+ .add(id);
+ return { ...prevMessageIdsMap, [fieldName]: fieldDescriptionIds };
+ });
+ },
+ [],
+ );
+ const handleFieldMessageIdRemove:
+ AriaDescriptionContextValue["onFieldMessageIdRemove"] = React
+ .useCallback(
+ (fieldName, id) => {
+ setMessageIdsMap((prevMessageIdsMap) => {
+ const fieldDescriptionIds = new Set(prevMessageIdsMap[fieldName]);
+ fieldDescriptionIds.delete(id);
+ return { ...prevMessageIdsMap, [fieldName]: fieldDescriptionIds };
+ });
+ },
+ [],
+ );
+ const getFieldDescription:
+ AriaDescriptionContextValue["getFieldDescription"] = React
+ .useCallback(
+ (fieldName) =>
+ Array.from(messageIdsMap[fieldName] ?? []).join(" ") || undefined,
+ [messageIdsMap],
+ );
+
+ return (
+
+
+ {
+ const firstInvalidControl = getFirstInvalidControl(
+ event.currentTarget,
+ );
+ if (firstInvalidControl === event.target) {
+ firstInvalidControl.focus();
+ }
+
+ // prevent default browser UI for form validation
+ event.preventDefault();
+ })}
+ // clear server errors when the form is re-submitted
+ onSubmit={composeEventHandlers(
+ props.onSubmit,
+ onClearServerErrors,
+ {
+ checkForDefaultPrevented: false,
+ },
+ )}
+ // clear server errors when the form is reset
+ onReset={composeEventHandlers(props.onReset, onClearServerErrors)}
+ />
+
+
+ );
+ },
+);
+
+Form.displayName = FORM_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * FormField
+ * -----------------------------------------------------------------------------------------------*/
+
+const FIELD_NAME = "FormField";
+
+type FormFieldContextValue = {
+ id: string;
+ name: string;
+ serverInvalid: boolean;
+};
+const [FormFieldProvider, useFormFieldContext] = createFormContext<
+ FormFieldContextValue
+>(FIELD_NAME);
+
+type FormFieldElement = React.ElementRef;
+type PrimitiveDivProps = Radix.ComponentPropsWithoutRef;
+interface FormFieldProps extends PrimitiveDivProps {
+ name: string;
+ serverInvalid?: boolean;
+}
+
+const FormField = React.forwardRef(
+ (props: ScopedProps, forwardedRef) => {
+ const { __scopeForm, name, serverInvalid = false, ...fieldProps } = props;
+ const validationContext = useValidationContext(FIELD_NAME, __scopeForm);
+ const validity = validationContext.getFieldValidity(name);
+ const id = useId();
+
+ return (
+