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
68 changes: 68 additions & 0 deletions src/components/drawer/Drawer.a11y.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hidden && el.tabIndex >= 0 && el.getAttribute("aria-hidden") !== "true",
);

export const focusFirst = (container: HTMLElement) => {
const autofocus = container.querySelector<HTMLElement>("[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();
}
};
107 changes: 84 additions & 23 deletions src/components/drawer/Drawer.classes.ts
Original file line number Diff line number Diff line change
@@ -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;
45 changes: 45 additions & 0 deletions src/components/drawer/Drawer.context.ts
Original file line number Diff line number Diff line change
@@ -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<DrawerContextValue>();

export const useDrawerContext = () => {
const ctx = useContext(DrawerContext);
if (!ctx) {
throw new Error("Drawer compound components must be used within <Drawer.Root>.");
}

return ctx;
};
104 changes: 102 additions & 2 deletions src/components/drawer/Drawer.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
@layer components {
.drawer {
position: relative;
}

/* -------------------------------------------------------------------------------------------------
* Trigger
* -----------------------------------------------------------------------------------------------*/
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -285,6 +384,7 @@
padding-bottom: 0.5rem;
}

.drawer__handle-bar,
.drawer__handle > [data-slot="drawer-handle-bar"] {
height: 0.25rem;
width: 2.25rem;
Expand Down
Loading
Loading