Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/react/components/ui/anchored-surface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Shared behavioral machinery for Popover and DropdownMenu.
* TODO(a11y): focus trap, portal + collision-aware positioning (flip/shift),
* aria-controls, side/align offsets.
* DropdownMenu: roving focus, typeahead, Tab, aria-activedescendant, sub menus.
* @module react/components/ui/anchored-surface
*/
import * as React from "react";
import { cx as cn } from "./cva.ts";
import { Slot } from "./slot.tsx";
import { Floating } from "./floating.tsx";
import { type DisclosureOptions, useDisclosure } from "./disclosure.ts";

/** Context value shared between an anchored skin's Root and its parts. */
export interface AnchoredState {
open: boolean;
setOpen: (open: boolean) => void;
anchorRef: React.RefObject<HTMLElement | null>;
}

/** Props for `AnchoredTrigger` (returned by the factory). */
export interface AnchoredTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
/** `aria-haspopup` value -- `"dialog"` for Popover, `"menu"` for DropdownMenu. */
haspopup: NonNullable<React.AriaAttributes["aria-haspopup"]>;
}

/** Props for `AnchoredContent` (returned by the factory). */
export interface AnchoredContentProps extends React.HTMLAttributes<HTMLDivElement> {
align?: "start" | "end";
}

/**
* Creates a fresh context instance plus the AnchoredRoot, AnchoredTrigger, and
* AnchoredContent parts -- all bound to that context.
*
* Each skin (Popover, DropdownMenu) calls this ONCE at module scope so their
* contexts are distinct objects. This prevents cross-binding when one skin is
* nested inside the other or inside a modal skin: a DropdownMenuItem close
* call only affects the DropdownMenu whose context is in scope, never a
* Popover above it in the tree.
*/
export function createAnchoredSurfaceParts() {
const Context = React.createContext<AnchoredState | null>(null);

/**
* Anchor `<span>` + disclosure state + context provider.
* The span is the positioning anchor for `Floating`.
*/
function AnchoredRoot(
{ children, open, defaultOpen, onOpenChange }: DisclosureOptions & {
children: React.ReactNode;
},
): React.ReactElement {
const { open: isOpen, setOpen } = useDisclosure({ open, defaultOpen, onOpenChange });
const anchorRef = React.useRef<HTMLElement | null>(null);
const ctx = React.useMemo(() => ({ open: isOpen, setOpen, anchorRef }), [isOpen, setOpen]);
return (
<span ref={anchorRef} className="relative inline-block">
<Context.Provider value={ctx}>
{children}
</Context.Provider>
</span>
);
}

/**
* Toggle trigger. Sets `aria-haspopup` and `aria-expanded`; toggles open on
* click. Skins differ only in the `haspopup` value they supply.
*/
function AnchoredTrigger(
{ children, asChild, onClick, haspopup, ...props }: AnchoredTriggerProps,
): React.ReactElement {
const ctx = React.useContext(Context);
const Comp = asChild ? Slot : "button";
return (
<Comp
{...(asChild ? {} : { type: "button" as const })}
aria-haspopup={haspopup}
aria-expanded={ctx?.open}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
// Guard ctx before reading ctx.open (trigger may render outside a Root).
if (ctx) ctx.setOpen(!ctx.open);
}}
{...props}
>
{children}
</Comp>
);
}

/** `Floating` wrapper with base classes. Skins extend via `className` and `role`. */
function AnchoredContent(
{ children, className, align, ...props }: AnchoredContentProps,
): React.ReactElement | null {
const ctx = React.useContext(Context);
if (!ctx) return null;
return (
<Floating
anchorRef={ctx.anchorRef}
open={ctx.open}
align={align}
onDismiss={() => ctx.setOpen(false)}
className={cn(
"z-50 overflow-hidden rounded-lg bg-[var(--popover)] text-[var(--foreground)] shadow-sm outline-none",
className,
)}
{...props}
>
{children}
</Floating>
);
}

return { Context, AnchoredRoot, AnchoredTrigger, AnchoredContent };
}
11 changes: 3 additions & 8 deletions src/react/components/ui/collapsible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import * as React from "react";
import { Slot } from "./slot.tsx";
import { useDisclosure } from "./disclosure.ts";

const CollapsibleContext = React.createContext<
{ open: boolean; toggle: () => void; disabled?: boolean } | null
Expand All @@ -33,14 +34,8 @@ export function Collapsible({
children,
...props
}: CollapsibleProps): React.ReactElement {
const [internal, setInternal] = React.useState(defaultOpen ?? false);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internal;
const toggle = React.useCallback(() => {
const next = !isOpen;
if (!isControlled) setInternal(next);
onOpenChange?.(next);
}, [isOpen, isControlled, onOpenChange]);
const { open: isOpen, setOpen } = useDisclosure({ open, defaultOpen, onOpenChange });
const toggle = React.useCallback(() => setOpen(!isOpen), [isOpen, setOpen]);
return (
<div data-state={isOpen ? "open" : "closed"} {...props}>
<CollapsibleContext.Provider value={{ open: isOpen, toggle, disabled }}>
Expand Down
147 changes: 33 additions & 114 deletions src/react/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,25 @@
* Trigger / Content + Header / Title / Description / Body / Footer / Action /
* Cancel / Close / Form). Classes ported 1:1 from Studio's `Dialog` (tokens
* remapped; `Heading` level 2 + `Text` inlined). Modal overlay + centered panel;
* dismisses on `Escape` and overlay click.
*
* TODO(a11y): focus trap + restore, `aria-labelledby`/`aria-describedby` wiring,
* scroll-lock, portal, enter/exit animation. Private to the chat module.
* dismisses on `Escape` and overlay click. A11y work tracked in modal-surface.tsx.
*
* @module react/components/ui/dialog
*/
import * as React from "react";
import { cx as cn } from "./cva.ts";
import { Slot } from "./slot.tsx";
import { ScrollFade } from "./scroll-fade.tsx";
import { Button, type ButtonProps, LoadingButton } from "./button.tsx";

const DialogContext = React.createContext<
{ open: boolean; setOpen: (open: boolean) => void } | null
>(null);

function useDialog() {
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error("Dialog parts must be used within <Dialog>");
return ctx;
}
import { createModalSurfaceParts } from "./modal-surface.tsx";

// Per-skin context + machinery -- distinct from Drawer's instance so a
// DrawerClose nested inside a Dialog cannot accidentally close the Dialog.
const {
ModalRoot: _Root,
useModal: _hook,
ModalTrigger: _Trigger,
ModalClose: _Close,
ModalContent: _Content,
} = createModalSurfaceParts("Dialog");

/** Props accepted by `<Dialog>`. */
export interface DialogProps {
Expand All @@ -35,47 +32,15 @@ export interface DialogProps {
}

/** Dialog root — owns open state. */
export function Dialog({
children,
open,
defaultOpen,
onOpenChange,
}: DialogProps): React.ReactElement {
const [internal, setInternal] = React.useState(defaultOpen ?? false);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internal;
const setOpen = React.useCallback((next: boolean) => {
if (!isControlled) setInternal(next);
onOpenChange?.(next);
}, [isControlled, onOpenChange]);
return (
<DialogContext.Provider value={{ open: isOpen, setOpen }}>
{children}
</DialogContext.Provider>
);
export function Dialog(props: DialogProps): React.ReactElement {
return <_Root {...props} />;
}

/** Trigger — opens the dialog. `asChild` merges onto the child element. */
export function DialogTrigger({
children,
asChild,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }): React.ReactElement {
const ctx = useDialog();
const Comp = asChild ? Slot : "button";
return (
<Comp
{...(asChild ? {} : { type: "button" as const })}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
ctx.setOpen(true);
}}
{...props}
>
{children}
</Comp>
);
export function DialogTrigger(
props: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean },
): React.ReactElement {
return <_Trigger {...props} />;
}

/** Modal surface — overlay + centered panel, rendered while open. */
Expand All @@ -84,47 +49,17 @@ export function DialogContent({
children,
...props
}: React.HTMLAttributes<HTMLDivElement>): React.ReactElement | null {
const ctx = useDialog();
const panelRef = React.useRef<HTMLDivElement>(null);

React.useEffect(() => {
if (!ctx.open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") ctx.setOpen(false);
};
document.addEventListener("keydown", onKeyDown);
// Focus the first focusable descendant on open (radix-like) — e.g. a
// CommandInput — falling back to the panel itself. Full focus-trap is TODO.
const panel = panelRef.current;
const focusable = panel?.querySelector<HTMLElement>(
'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
);
(focusable ?? panel)?.focus();
return () => document.removeEventListener("keydown", onKeyDown);
}, [ctx.open]);

if (!ctx.open) return null;
return (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-[var(--overlay)]"
onClick={() => ctx.setOpen(false)}
/>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-[calc(100%-3rem)] max-w-xl max-h-[85vh] -translate-x-1/2 -translate-y-1/2",
"rounded-xl bg-[var(--dialog)] text-[var(--foreground)] shadow-lg outline-none overflow-hidden flex flex-col",
className,
)}
{...props}
>
{children}
</div>
</div>
<_Content
className={cn(
"fixed left-1/2 top-1/2 z-50 w-[calc(100%-3rem)] max-w-xl max-h-[85vh] -translate-x-1/2 -translate-y-1/2",
"rounded-xl bg-[var(--dialog)] text-[var(--foreground)] shadow-lg outline-none overflow-hidden flex flex-col",
className,
)}
{...props}
>
{children}
</_Content>
);
}

Expand Down Expand Up @@ -231,7 +166,7 @@ export function DialogCancel({
onClick,
...props
}: ButtonProps): React.ReactElement {
const ctx = useDialog();
const ctx = _hook();
return (
<Button
variant={variant}
Expand All @@ -247,24 +182,8 @@ export function DialogCancel({
}

/** Closes the dialog. `asChild` merges onto the child element. */
export function DialogClose({
children,
asChild,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }): React.ReactElement {
const ctx = useDialog();
const Comp = asChild ? Slot : "button";
return (
<Comp
{...(asChild ? {} : { type: "button" as const })}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
ctx.setOpen(false);
}}
{...props}
>
{children}
</Comp>
);
export function DialogClose(
props: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean },
): React.ReactElement {
return <_Close {...props} />;
}
32 changes: 32 additions & 0 deletions src/react/components/ui/disclosure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* useDisclosure: shared controlled/uncontrolled open state for overlay surfaces.
* @module react/components/ui/disclosure
*/
import * as React from "react";

/** Options accepted by `useDisclosure`. */
export interface DisclosureOptions {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}

/** Returns `{ open, setOpen }`, handling controlled and uncontrolled usage. */
export function useDisclosure({ open, defaultOpen, onOpenChange }: DisclosureOptions) {
const [internal, setInternal] = React.useState(defaultOpen ?? false);
const isControlled = open !== undefined;
const isOpen: boolean = open ?? internal;
// Latest-ref pattern: setOpen keeps a stable identity across parent renders
// (so effect consumers do not re-register listeners) while always invoking
// the caller's current onOpenChange.
const onOpenChangeRef = React.useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternal(next);
onOpenChangeRef.current?.(next);
},
[isControlled],
);
return { open: isOpen, setOpen };
}
Loading