From 3961a1af5d711a1de53d4529d301c8e4190f4c37 Mon Sep 17 00:00:00 2001 From: Diego Martinez Date: Wed, 15 Apr 2026 13:57:45 -0400 Subject: [PATCH] feat(ui-drawer): add root behavior, a11y contract, and variant class map --- src/components/drawer/Drawer.a11y.ts | 68 +++ src/components/drawer/Drawer.classes.ts | 107 +++- src/components/drawer/Drawer.context.ts | 45 ++ src/components/drawer/Drawer.css | 104 +++- src/components/drawer/Drawer.tsx | 678 +++++++++++++++--------- src/components/drawer/index.ts | 2 + src/index.ts | 3 + 7 files changed, 726 insertions(+), 281 deletions(-) create mode 100644 src/components/drawer/Drawer.a11y.ts create mode 100644 src/components/drawer/Drawer.context.ts diff --git a/src/components/drawer/Drawer.a11y.ts b/src/components/drawer/Drawer.a11y.ts new file mode 100644 index 0000000..84c9e3a --- /dev/null +++ b/src/components/drawer/Drawer.a11y.ts @@ -0,0 +1,68 @@ +export type DrawerPlacement = "top" | "bottom" | "left" | "right"; +export type DrawerSize = "sm" | "md" | "lg" | "full"; +export type DrawerBackdropVariant = "opaque" | "blur" | "transparent"; +export type DrawerScrollBehavior = "inside" | "outside"; +export type DrawerAnimState = "entering" | "open" | "exiting" | "closed"; +export type DrawerCloseReason = "escape" | "backdrop" | "trigger" | "api"; + +export const isSidePlacement = (placement: DrawerPlacement) => + placement === "left" || placement === "right"; + +export const isVisibleState = (state: DrawerAnimState) => + state === "entering" || state === "open"; + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "area[href]", + "button:not([disabled])", + "input:not([disabled]):not([type='hidden'])", + "select:not([disabled])", + "textarea:not([disabled])", + "[contenteditable='true']", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +export const getFocusable = (container: HTMLElement) => + Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => !el.hidden && el.tabIndex >= 0 && el.getAttribute("aria-hidden") !== "true", + ); + +export const focusFirst = (container: HTMLElement) => { + const autofocus = container.querySelector("[autofocus]"); + if (autofocus) { + autofocus.focus(); + return; + } + + const nodes = getFocusable(container); + if (nodes.length > 0) { + nodes[0].focus(); + return; + } + + container.focus(); +}; + +export const trapFocus = (event: KeyboardEvent, container: HTMLElement) => { + const nodes = getFocusable(container); + if (nodes.length === 0) { + event.preventDefault(); + container.focus(); + return; + } + + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + const active = document.activeElement as HTMLElement | null; + + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + return; + } + + if (event.shiftKey && (active === first || active === container)) { + event.preventDefault(); + last.focus(); + } +}; diff --git a/src/components/drawer/Drawer.classes.ts b/src/components/drawer/Drawer.classes.ts index f9d6e86..866a72c 100644 --- a/src/components/drawer/Drawer.classes.ts +++ b/src/components/drawer/Drawer.classes.ts @@ -1,28 +1,89 @@ export const CLASSES = { - slot: { - trigger: "drawer__trigger", - backdrop: "drawer__backdrop", - content: "drawer__content", - dialog: "drawer__dialog", - header: "drawer__header", - heading: "drawer__heading", - body: "drawer__body", - footer: "drawer__footer", - handle: "drawer__handle", - closeTrigger: "drawer__close-trigger", - closeIcon: "drawer__close-icon", + Root: { + base: "drawer", + state: { + open: "drawer--open", + entering: "drawer--entering", + exiting: "drawer--exiting", + closed: "drawer--closed", + }, }, - backdrop: { - opaque: "drawer__backdrop--opaque", - blur: "drawer__backdrop--blur", - transparent: "drawer__backdrop--transparent", + + Trigger: { + base: "drawer__trigger", }, - placement: { - top: "drawer__content--top", - bottom: "drawer__content--bottom", - left: "drawer__content--left", - right: "drawer__content--right", + + Backdrop: { + base: "drawer__backdrop", + variant: { + opaque: "drawer__backdrop--opaque", + blur: "drawer__backdrop--blur", + transparent: "drawer__backdrop--transparent", + }, + state: { + entering: "drawer__backdrop--entering", + exiting: "drawer__backdrop--exiting", + }, + }, + + Content: { + base: "drawer__content", + placement: { + top: "drawer__content--top", + bottom: "drawer__content--bottom", + left: "drawer__content--left", + right: "drawer__content--right", + }, + scroll: { + inside: "drawer__content--scroll-inside", + outside: "drawer__content--scroll-outside", + }, + state: { + entering: "drawer__content--entering", + exiting: "drawer__content--exiting", + }, + }, + + Dialog: { + base: "drawer__dialog", + axis: { + side: "drawer__dialog--axis-side", + edge: "drawer__dialog--axis-edge", + }, + size: { + side: { + sm: "drawer__dialog--side-sm", + md: "drawer__dialog--side-md", + lg: "drawer__dialog--side-lg", + full: "drawer__dialog--side-full", + }, + edge: { + sm: "drawer__dialog--edge-sm", + md: "drawer__dialog--edge-md", + lg: "drawer__dialog--edge-lg", + full: "drawer__dialog--edge-full", + }, + }, + state: { + entering: "drawer__dialog--entering", + exiting: "drawer__dialog--exiting", + }, + }, + + Header: { base: "drawer__header" }, + Heading: { base: "drawer__heading" }, + Body: { base: "drawer__body" }, + Footer: { base: "drawer__footer" }, + + Handle: { + base: "drawer__handle", + bar: "drawer__handle-bar", + }, + + CloseTrigger: { + base: "drawer__close-trigger", + icon: "drawer__close-icon", + iconStart: "drawer__close-icon--start", + iconEnd: "drawer__close-icon--end", }, - closeIconStart: "drawer__close-icon--start", - closeIconEnd: "drawer__close-icon--end", } as const; diff --git a/src/components/drawer/Drawer.context.ts b/src/components/drawer/Drawer.context.ts new file mode 100644 index 0000000..4b137f2 --- /dev/null +++ b/src/components/drawer/Drawer.context.ts @@ -0,0 +1,45 @@ +import { createContext, useContext } from "solid-js"; +import type { + DrawerAnimState, + DrawerBackdropVariant, + DrawerCloseReason, + DrawerPlacement, + DrawerScrollBehavior, + DrawerSize, +} from "./Drawer.a11y"; + +export type DrawerContextValue = { + isOpen: () => boolean; + setIsOpen: (next: boolean) => void; + requestClose: (reason: DrawerCloseReason) => void; + animState: () => DrawerAnimState; + placement: () => DrawerPlacement; + size: () => DrawerSize; + backdrop: () => DrawerBackdropVariant; + scrollBehavior: () => DrawerScrollBehavior; + isDismissable: () => boolean; + shouldCloseOnEsc: () => boolean; + shouldCloseOnBackdropClick: () => boolean; + trapFocus: () => boolean; + restoreFocus: () => boolean; + dialogRef: () => HTMLDivElement | undefined; + setDialogRef: (node: HTMLDivElement | undefined) => void; + labelledBy: () => string | undefined; + setLabelledBy: (id: string | undefined) => void; + describedBy: () => string | undefined; + setDescribedBy: (id: string | undefined) => void; + setPlacementOverride: (value: DrawerPlacement | undefined) => void; + setBackdropDismissableOverride: (value: boolean | undefined) => void; + setBackdropCloseOnClickOverride: (value: boolean | undefined) => void; +}; + +export const DrawerContext = createContext(); + +export const useDrawerContext = () => { + const ctx = useContext(DrawerContext); + if (!ctx) { + throw new Error("Drawer compound components must be used within ."); + } + + return ctx; +}; diff --git a/src/components/drawer/Drawer.css b/src/components/drawer/Drawer.css index 0083248..1a0792d 100644 --- a/src/components/drawer/Drawer.css +++ b/src/components/drawer/Drawer.css @@ -1,4 +1,8 @@ @layer components { + .drawer { + position: relative; + } + /* ------------------------------------------------------------------------------------------------- * Trigger * -----------------------------------------------------------------------------------------------*/ @@ -34,6 +38,15 @@ transition: opacity 250ms cubic-bezier(0.32, 0.72, 0, 1); } + .drawer__backdrop--entering { + opacity: 0; + } + + .drawer__backdrop--exiting { + opacity: 0; + transition-duration: 200ms; + } + .drawer__backdrop[data-entering="true"] { opacity: 0; } @@ -77,6 +90,42 @@ height: 100dvh; } + .drawer__content--scroll-inside .drawer__dialog { + overflow: hidden; + } + + .drawer__content--scroll-inside .drawer__body { + overflow-y: auto; + } + + .drawer__content--scroll-outside { + overflow-y: auto; + } + + .drawer__content--scroll-outside .drawer__dialog { + margin-block: auto; + } + + .drawer__content--entering[data-placement="left"] .drawer__dialog, + .drawer__content--exiting[data-placement="left"] .drawer__dialog { + translate: -100% 0; + } + + .drawer__content--entering[data-placement="right"] .drawer__dialog, + .drawer__content--exiting[data-placement="right"] .drawer__dialog { + translate: 100% 0; + } + + .drawer__content--entering[data-placement="top"] .drawer__dialog, + .drawer__content--exiting[data-placement="top"] .drawer__dialog { + translate: 0 -100%; + } + + .drawer__content--entering[data-placement="bottom"] .drawer__dialog, + .drawer__content--exiting[data-placement="bottom"] .drawer__dialog { + translate: 0 100%; + } + .drawer__content--bottom { align-items: flex-end; } @@ -117,6 +166,57 @@ transition: translate var(--drawer-enter-duration) var(--drawer-ease); } + .drawer__dialog--axis-side { + height: 100%; + border-radius: 0; + } + + .drawer__dialog--axis-edge { + width: 100%; + } + + .drawer__dialog--side-sm { + width: 18rem; + max-width: 85vw; + } + + .drawer__dialog--side-md { + width: 20rem; + max-width: 85vw; + } + + .drawer__dialog--side-lg { + width: 24rem; + max-width: 90vw; + } + + .drawer__dialog--side-full { + width: 100vw; + max-width: 100vw; + } + + .drawer__dialog--edge-sm { + max-height: min(40vh, 24rem); + } + + .drawer__dialog--edge-md { + max-height: min(60vh, 36rem); + } + + .drawer__dialog--edge-lg { + max-height: 85vh; + } + + .drawer__dialog--edge-full { + max-height: 100vh; + border-radius: 0; + } + + .drawer__dialog--entering, + .drawer__dialog--exiting { + transition-duration: var(--drawer-exit-duration); + } + [data-exiting="true"] .drawer__dialog { transition-duration: var(--drawer-exit-duration); } @@ -192,7 +292,6 @@ .drawer__dialog--custom-bg { background-color: var(--drawer-dialog-bg, var(--color-base-100)); } - /* Slide transitions — open state */ .drawer__content--left .drawer__dialog, .drawer__content--right .drawer__dialog, @@ -201,7 +300,7 @@ translate: 0 0; } - /* Entering/exiting slide transforms */ + /* Entering/exiting slide transforms (legacy data-attrs support) */ .drawer__content--left[data-entering="true"] .drawer__dialog, .drawer__content--left[data-exiting="true"] .drawer__dialog { translate: -100% 0; @@ -285,6 +384,7 @@ padding-bottom: 0.5rem; } + .drawer__handle-bar, .drawer__handle > [data-slot="drawer-handle-bar"] { height: 0.25rem; width: 2.25rem; diff --git a/src/components/drawer/Drawer.tsx b/src/components/drawer/Drawer.tsx index 4df53ef..336f158 100644 --- a/src/components/drawer/Drawer.tsx +++ b/src/components/drawer/Drawer.tsx @@ -1,56 +1,91 @@ import "./Drawer.css"; import { - createContext, + Show, createEffect, createSignal, + createUniqueId, onCleanup, splitProps, - useContext, - Show, type Component, type JSX, type ParentComponent, } from "solid-js"; -import { twMerge } from "tailwind-merge"; import { Portal } from "solid-js/web"; +import { twMerge } from "tailwind-merge"; import type { IComponentBaseProps } from "../types"; +import { + focusFirst, + isSidePlacement, + isVisibleState, + trapFocus, + type DrawerAnimState, + type DrawerBackdropVariant, + type DrawerCloseReason, + type DrawerPlacement, + type DrawerScrollBehavior, + type DrawerSize, +} from "./Drawer.a11y"; import { CLASSES } from "./Drawer.classes"; - -/* ------------------------------------------------------------------------------------------------- - * Drawer Context - * -----------------------------------------------------------------------------------------------*/ -export type DrawerPlacement = "top" | "bottom" | "left" | "right"; -export type DrawerBackdropVariant = "opaque" | "blur" | "transparent"; - -type DrawerAnimState = "entering" | "open" | "exiting" | "closed"; - -type DrawerContextValue = { - isOpen: () => boolean; - setIsOpen: (v: boolean) => void; - placement: () => DrawerPlacement; - isDismissable: () => boolean; - animState: () => DrawerAnimState; +import { DrawerContext, useDrawerContext, type DrawerContextValue } from "./Drawer.context"; + +export type { + DrawerPlacement, + DrawerSize, + DrawerBackdropVariant, + DrawerScrollBehavior, +} from "./Drawer.a11y"; + +/* --------------------------- body-scroll locking -------------------------- */ + +let bodyLockCount = 0; +let prevBodyOverflow = ""; +let prevBodyPaddingRight = ""; + +const lockBodyScroll = () => { + if (bodyLockCount === 0) { + prevBodyOverflow = document.body.style.overflow; + prevBodyPaddingRight = document.body.style.paddingRight; + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + document.body.style.overflow = "hidden"; + } + bodyLockCount += 1; }; -const DrawerContext = createContext(); - -const useDrawerContext = () => { - const ctx = useContext(DrawerContext); - if (!ctx) throw new Error("Drawer compound components must be used within "); - return ctx; +const unlockBodyScroll = () => { + if (bodyLockCount <= 0) return; + bodyLockCount -= 1; + if (bodyLockCount === 0) { + document.body.style.overflow = prevBodyOverflow; + document.body.style.paddingRight = prevBodyPaddingRight; + } }; -/* ------------------------------------------------------------------------------------------------- - * Types - * -----------------------------------------------------------------------------------------------*/ -export type DrawerRootProps = IComponentBaseProps & { - children: JSX.Element; - isOpen?: boolean; - defaultOpen?: boolean; - onOpenChange?: (isOpen: boolean) => void; -}; +/* --------------------------------- props --------------------------------- */ -export type DrawerTriggerProps = Omit, "children"> & +export type DrawerRootProps = Omit, "children"> & + IComponentBaseProps & { + children: JSX.Element; + isOpen?: boolean; + defaultOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + placement?: DrawerPlacement; + size?: DrawerSize; + backdrop?: DrawerBackdropVariant; + scrollBehavior?: DrawerScrollBehavior; + isDismissable?: boolean; + shouldCloseOnEsc?: boolean; + shouldCloseOnBackdropClick?: boolean; + trapFocus?: boolean; + restoreFocus?: boolean; + }; + +export type DrawerTriggerProps = Omit< + JSX.ButtonHTMLAttributes, + "children" +> & IComponentBaseProps & { children: JSX.Element; }; @@ -59,13 +94,18 @@ export type DrawerBackdropProps = Omit, "chil IComponentBaseProps & { children: JSX.Element; variant?: DrawerBackdropVariant; + /** @deprecated Configure dismissability at Drawer.Root `isDismissable` */ isDismissable?: boolean; + /** @deprecated Configure backdrop closing at Drawer.Root `shouldCloseOnBackdropClick` */ + shouldCloseOnBackdropClick?: boolean; }; export type DrawerContentProps = Omit, "children"> & IComponentBaseProps & { children: JSX.Element; + /** @deprecated Configure placement at Drawer.Root `placement` */ placement?: DrawerPlacement; + scrollBehavior?: DrawerScrollBehavior; }; export type DrawerDialogSide = "left" | "right"; @@ -80,6 +120,7 @@ export type DrawerDialogProps = Omit, "childr padding?: string; borderWidth?: string; borderColor?: string; + size?: DrawerSize; }; export type DrawerHeaderProps = Omit, "children"> & @@ -90,11 +131,13 @@ export type DrawerHeaderProps = Omit, "childr export type DrawerHeadingProps = Omit, "children"> & IComponentBaseProps & { children: JSX.Element; + id?: string; }; export type DrawerBodyProps = Omit, "children"> & IComponentBaseProps & { children: JSX.Element; + id?: string; }; export type DrawerFooterProps = Omit, "children"> & @@ -104,56 +147,230 @@ export type DrawerFooterProps = Omit, "childr export type DrawerHandleProps = JSX.HTMLAttributes & IComponentBaseProps; -export type DrawerCloseTriggerProps = Omit, "children"> & +export type DrawerCloseTriggerProps = Omit< + JSX.ButtonHTMLAttributes, + "children" +> & IComponentBaseProps & { children?: JSX.Element; startIcon?: JSX.Element; endIcon?: JSX.Element; }; -/* ------------------------------------------------------------------------------------------------- - * Drawer Root - * -----------------------------------------------------------------------------------------------*/ +export type DrawerCloseProps = { + children: JSX.Element; +}; + +/* ---------------------------------- root --------------------------------- */ + +const EXIT_MS = 200; + const DrawerRoot: ParentComponent = (props) => { - const [local, _others] = splitProps(props, [ + const [local, others] = splitProps(props, [ "children", - "isOpen", - "defaultOpen", - "onOpenChange", - "dataTheme", "class", "className", + "dataTheme", "style", + "isOpen", + "defaultOpen", + "onOpenChange", + "placement", + "size", + "backdrop", + "scrollBehavior", + "isDismissable", + "shouldCloseOnEsc", + "shouldCloseOnBackdropClick", + "trapFocus", + "restoreFocus", ]); const [internalOpen, setInternalOpen] = createSignal(Boolean(local.defaultOpen)); + const [animState, setAnimState] = createSignal( + Boolean(local.isOpen ?? local.defaultOpen) ? "open" : "closed", + ); + + const [dialogRef, setDialogRef] = createSignal(); + const [labelledBy, setLabelledBy] = createSignal(); + const [describedBy, setDescribedBy] = createSignal(); + + const [placementOverride, setPlacementOverride] = createSignal( + undefined, + ); + const [backdropDismissableOverride, setBackdropDismissableOverride] = createSignal< + boolean | undefined + >(undefined); + const [backdropCloseOnClickOverride, setBackdropCloseOnClickOverride] = createSignal< + boolean | undefined + >(undefined); const isControlled = () => local.isOpen !== undefined; const isOpen = () => (isControlled() ? Boolean(local.isOpen) : internalOpen()); - const setIsOpen = (v: boolean) => { - if (!isControlled()) setInternalOpen(v); - local.onOpenChange?.(v); + const placement = () => placementOverride() ?? local.placement ?? "bottom"; + const size = () => local.size ?? "md"; + const backdrop = () => local.backdrop ?? "opaque"; + const scrollBehavior = () => local.scrollBehavior ?? "inside"; + const isDismissable = () => backdropDismissableOverride() ?? local.isDismissable ?? true; + const shouldCloseOnEsc = () => local.shouldCloseOnEsc ?? true; + const shouldCloseOnBackdropClick = () => + backdropCloseOnClickOverride() ?? local.shouldCloseOnBackdropClick ?? true; + const trapFocusEnabled = () => local.trapFocus ?? true; + const restoreFocusEnabled = () => local.restoreFocus ?? true; + + const setIsOpen = (next: boolean) => { + if (next === isOpen()) return; + if (!isControlled()) setInternalOpen(next); + local.onOpenChange?.(next); + }; + + const requestClose = (reason: DrawerCloseReason) => { + if (!isDismissable()) return; + if (reason === "escape" && !shouldCloseOnEsc()) return; + if (reason === "backdrop" && !shouldCloseOnBackdropClick()) return; + setIsOpen(false); }; - const ctx: DrawerContextValue = { + let exitTimer: ReturnType | undefined; + + createEffect(() => { + const open = isOpen(); + const state = animState(); + + if (open) { + if (exitTimer) { + clearTimeout(exitTimer); + exitTimer = undefined; + } + if (state === "closed" || state === "exiting") { + setAnimState("entering"); + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimState("open")); + }); + } + return; + } + + if (state === "open" || state === "entering") { + setAnimState("exiting"); + exitTimer = setTimeout(() => setAnimState("closed"), EXIT_MS); + } + }); + + onCleanup(() => { + if (exitTimer) clearTimeout(exitTimer); + }); + + let hasScrollLock = false; + createEffect(() => { + const visible = isVisibleState(animState()); + if (visible && !hasScrollLock) { + lockBodyScroll(); + hasScrollLock = true; + } else if (!visible && hasScrollLock) { + unlockBodyScroll(); + hasScrollLock = false; + } + }); + + onCleanup(() => { + if (hasScrollLock) unlockBodyScroll(); + }); + + let restoreFocusTarget: HTMLElement | null = null; + createEffect(() => { + const state = animState(); + const dialog = dialogRef(); + if (!isVisibleState(state) || !dialog) return; + + if (!restoreFocusTarget) { + const active = document.activeElement; + if (active instanceof HTMLElement) restoreFocusTarget = active; + } + + queueMicrotask(() => { + if (trapFocusEnabled() && !dialog.contains(document.activeElement)) { + focusFirst(dialog); + } + }); + + const onKeyDown = (event: KeyboardEvent) => { + if (!isVisibleState(animState())) return; + + if (event.key === "Escape") { + event.preventDefault(); + requestClose("escape"); + return; + } + + if (event.key === "Tab" && trapFocusEnabled()) { + trapFocus(event, dialog); + } + }; + + document.addEventListener("keydown", onKeyDown); + onCleanup(() => document.removeEventListener("keydown", onKeyDown)); + }); + + createEffect(() => { + if (animState() !== "closed" || !restoreFocusEnabled()) return; + if (!restoreFocusTarget) return; + queueMicrotask(() => { + restoreFocusTarget?.focus?.(); + restoreFocusTarget = null; + }); + }); + + const contextValue: DrawerContextValue = { isOpen, setIsOpen, - placement: () => "bottom", - isDismissable: () => true, - animState: () => "closed" as DrawerAnimState, + requestClose, + animState, + placement, + size, + backdrop, + scrollBehavior, + isDismissable, + shouldCloseOnEsc, + shouldCloseOnBackdropClick, + trapFocus: trapFocusEnabled, + restoreFocus: restoreFocusEnabled, + dialogRef, + setDialogRef, + labelledBy, + setLabelledBy, + describedBy, + setDescribedBy, + setPlacementOverride, + setBackdropDismissableOverride, + setBackdropCloseOnClickOverride, }; return ( - - {local.children} + +
+ {local.children} +
); }; -/* ------------------------------------------------------------------------------------------------- - * Drawer Trigger - * -----------------------------------------------------------------------------------------------*/ const DrawerTrigger: Component = (props) => { const [local, others] = splitProps(props, [ "children", @@ -166,16 +383,16 @@ const DrawerTrigger: Component = (props) => { const ctx = useDrawerContext(); - const handleClick: JSX.EventHandlerUnion = (e) => { + const handleClick: JSX.EventHandlerUnion = (event) => { ctx.setIsOpen(true); - if (typeof local.onClick === "function") local.onClick(e); + if (typeof local.onClick === "function") local.onClick(event); }; return (