Skip to content

Commit

Permalink
feat: various ARIA enhancements for Overlay (Dialog & Drawer) (#85)
Browse files Browse the repository at this point in the history
* feat: various ARIA enhancements for Overlay (Dialog & Drawer)

* test: resolve focus trap issue
  • Loading branch information
jaredcwhite authored May 7, 2024
1 parent 4729910 commit b081ea2
Show file tree
Hide file tree
Showing 8 changed files with 95 additions and 50 deletions.
4 changes: 4 additions & 0 deletions src/global/text.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
.text-heading-xs {
margin-block: 0;
color: var(--seeds-text-color-darker);

&:focus-visible {
outline: none;
}
}

.text-heading-4xl,
Expand Down
28 changes: 11 additions & 17 deletions src/overlays/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import React, { useEffect, useRef } from "react"

import Overlay, { OverlayContent, OverlayContentProps, OverlayFooter, OverlayFooterProps, OverlayHeader, OverlayHeaderProps } from "./Overlay"
import Overlay, {
OverlayProps,
OverlayContent,
OverlayContentProps,
OverlayFooter,
OverlayFooterProps,
OverlayHeader,
OverlayHeaderProps,
} from "./Overlay"

import "./Dialog.scss"

Expand All @@ -25,17 +33,7 @@ const DialogFooter = (props: OverlayFooterProps) => {
return <OverlayFooter {...props} className={classNames.join(" ")} />
}

export interface DialogProps {
children: React.ReactNode
/** Additional class name */
className?: string
/** If this dialog is open */
isOpen: boolean
/** Function to call when hitting ESC or clicking background overlay */
onClose: () => void
/** Additional class name for the Overlay */
overlayClassName?: string
}
export interface DialogProps extends OverlayProps {}

const Dialog = (props: DialogProps) => {
if (!props.isOpen) return null
Expand All @@ -44,11 +42,7 @@ const Dialog = (props: DialogProps) => {
if (props.className) classNames.push(props.className)

return (
<Overlay
className={classNames.join(" ")}
overlayClassName={props.overlayClassName}
onClose={props.onClose}
>
<Overlay {...props} className={classNames.join(" ")}>
{props.children}
</Overlay>
)
Expand Down
15 changes: 3 additions & 12 deletions src/overlays/Drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Overlay, {
OverlayContentProps,
OverlayHeader,
OverlayHeaderProps,
OverlayProps,
} from "./Overlay"

import "./Drawer.scss"
Expand Down Expand Up @@ -37,18 +38,9 @@ const DrawerFooter = (props: OverlayFooterProps) => {
return <OverlayFooter {...props} className={classNames.join(" ")} />
}

export interface DrawerProps {
children: React.ReactNode
/** Additional class name */
className?: string
/** If this Drawer is open */
isOpen: boolean
/** Function to call when hitting ESC or clicking background overlay */
onClose: () => void
export interface DrawerProps extends OverlayProps {
/** If this Drawer renders nested above another Drawer */
nested?: boolean
/** Additional class name for the Overlay */
overlayClassName?: string
}

const Drawer = (props: DrawerProps) => {
Expand All @@ -60,9 +52,8 @@ const Drawer = (props: DrawerProps) => {

return (
<Overlay
{...props}
className={classNames.join(" ")}
overlayClassName={props.overlayClassName}
onClose={props.onClose}
>
{props.children}
</Overlay>
Expand Down
15 changes: 15 additions & 0 deletions src/overlays/Overlay.scss
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,27 @@
justify-content: space-between;
}

&:not(.has-close-button-end) {
justify-content: start;
flex-direction: row-reverse;
}

> button {
padding: 0;
background-color: transparent;
background-image: none;
border: none;
border-radius: var(--seeds-rounded);
cursor: pointer;

&:focus {
outline: none;
}

&:focus-visible {
outline: var(--seeds-focus-ring-outline);
box-shadow: var(--seeds-focus-ring-box-shadow);
}
}

> h1 {
Expand Down
57 changes: 42 additions & 15 deletions src/overlays/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import usePortal from "../hooks/usePortal"

import "./Overlay.scss"


export interface OverlayHeaderProps {
children: React.ReactNode
/** Place the close button at the inline end of the header */
closeButtonEnd?: boolean
/** Additional class name */
className?: string
/** Element ID */
id?: string
}

const OverlayHeader = (props: OverlayHeaderProps) => {
Expand All @@ -23,24 +24,30 @@ const OverlayHeader = (props: OverlayHeaderProps) => {
if (props.className) classNames.push(props.className)

const headerRef = useRef<HTMLElement>(null)
// Focus on the heading on render so that it is read by screen readers
useEffect(() => {
headerRef.current?.querySelector<HTMLElement>(".seeds-overlay-heading")?.focus()
}, [])

const closeButton = (
<button onClick={() => headerRef.current?.dispatchEvent(new Event("seeds:close", { bubbles: true }))}>
<Icon size={"lg"} className={"seeds-overlay-close-icon"}><XMarkIcon /></Icon>
<button
aria-label="Close"
onClick={() => headerRef.current?.dispatchEvent(new Event("seeds:close", { bubbles: true }))}
>
<Icon size={"lg"} className={"seeds-overlay-close-icon"}>
<XMarkIcon />
</Icon>
</button>
)

return (
<header className={classNames.join(" ")} ref={headerRef}>
{!props.closeButtonEnd && closeButton}
<Heading priority={1} size="xl" className={"seeds-overlay-heading"} tabIndex={-1}>
<Heading
id={props.id}
priority={1}
size="xl"
className={"seeds-overlay-heading"}
tabIndex={-1}
>
{props.children}
</Heading>
{props.closeButtonEnd && closeButton}
{closeButton}
</header>
)
}
Expand All @@ -49,13 +56,19 @@ export interface OverlayContentProps {
children: React.ReactNode
/** Additional class name */
className?: string
/** Element ID */
id?: string
}

const OverlayContent = (props: OverlayContentProps) => {
const classNames = ["seeds-overlay-content"]
if (props.className) classNames.push(props.className)

return <div className={classNames.join(" ")}>{props.children}</div>
return (
<div id={props.id} className={classNames.join(" ")}>
{props.children}
</div>
)
}

export interface OverlayFooterProps {
Expand All @@ -71,14 +84,20 @@ const OverlayFooter = (props: OverlayFooterProps) => {
return <footer className={classNames.join(" ")}>{props.children}</footer>
}


export interface OverlayProps {
children: React.ReactNode
/** Function to call when clicking the close icon */
/** Function to call when hitting ESC or clicking background overlay */
onClose: () => void
/** If this Overlay is open */
isOpen?: boolean
/** Additional class name for the Overlay */
overlayClassName?: string
/** Additional class name */
className?: string
/** An ID of a heading element that titles the Overlay */
ariaLabelledBy?: string
/** An ID for content content */
ariaDescribedBy?: string
}

const Overlay = (props: OverlayProps) => {
Expand All @@ -101,10 +120,18 @@ const Overlay = (props: OverlayProps) => {
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true,
fallbackFocus: `#${uniqueFocusId}`,
initialFocus: `#${props.ariaLabelledBy || uniqueFocusId}`,
}}
>
<div id={uniqueFocusId} className={classNames.join(" ")} role="dialog">
<div
id={uniqueFocusId}
tabIndex={-1}
className={classNames.join(" ")}
role="dialog"
aria-modal="true"
aria-labelledby={props.ariaLabelledBy}
aria-describedby={props.ariaDescribedBy}
>
{props.children}
</div>
</FocusTrap>
Expand Down
14 changes: 14 additions & 0 deletions src/overlays/__mocks__/tabbable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Solution taken from:
// https://github.com/focus-trap/tabbable#testing-in-jsdom

const lib = jest.requireActual('tabbable');

const tabbable = {
...lib,
tabbable: (node, options) => lib.tabbable(node, { ...options, displayCheck: 'none' }),
focusable: (node, options) => lib.focusable(node, { ...options, displayCheck: 'none' }),
isFocusable: (node, options) => lib.isFocusable(node, { ...options, displayCheck: 'none' }),
isTabbable: (node, options) => lib.isTabbable(node, { ...options, displayCheck: 'none' }),
};

module.exports = tabbable;
6 changes: 3 additions & 3 deletions src/overlays/__stories__/Dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ export const Default = () => {
return (
<>
<button onClick={() => setIsOpen(!isOpen)}>Toggle Dialog</button>
<Dialog isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Dialog.Header>
<Dialog isOpen={isOpen} onClose={() => setIsOpen(false)} ariaLabelledBy="conf-needed" ariaDescribedBy="conf-details">
<Dialog.Header id="conf-needed">
Confirmation needed
</Dialog.Header>
<Dialog.Content>
<Dialog.Content id="conf-details">
<p>An email has been sent to [email protected]</p>
<p>Please click on the link in the email we sent you in order to complete account creation.</p>
</Dialog.Content>
Expand Down
6 changes: 3 additions & 3 deletions src/overlays/__stories__/Drawer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ export const Default = () => {
return (
<>
<button onClick={() => setIsOpen(!isOpen)}>Toggle Drawer</button>
<Drawer isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Drawer.Header>
<Drawer isOpen={isOpen} onClose={() => setIsOpen(false)} ariaLabelledBy="drawer-heading" ariaDescribedBy="drawer-content">
<Drawer.Header id="drawer-heading">
Heading
</Drawer.Header>
<Drawer.Content>
<Drawer.Content id="drawer-content">
<CardExample />
</Drawer.Content>
<Drawer.Footer>
Expand Down

0 comments on commit b081ea2

Please sign in to comment.