Return

All tests / src/components/Dialog index.tsx

100% Statements 17/17
94.73% Branches 18/19
100% Functions 4/4
100% Lines 13/13

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 1263x 3x 3x                                                                                       3x                         17x   17x       17x 14x 1x 1x   1x 1x                                                                                                         17x  
import React, { ReactNode } from 'react';
import styles from './dialog.module.css';
import { clx } from '@/helpers';
 
type Props = {
    dialogRef?: React.RefObject<HTMLDivElement | null>;
    className?: string;
    open?: boolean;
    withPadding?: boolean;
    modalMode?: boolean;
    header?: ReactNode;
    body?: ReactNode;
    footer?: ReactNode;
    large?: boolean;
    /** Accessible name for the window. Falls back to labelling by the header's content. */
    label?: string;
    /** Called on Escape. Omit for a window that has no way to close. */
    onClose?: () => void;
};
 
/**
 * @example
 *     <Dialog
 *         dialogRef={dialogRef}
 *         open={open}
 *         withPadding={withPadding}
 *         modalMode={modalMode}
 *         header={<h1>Header</h1>}
 *         body={<p>Body</p>}
 *         footer={<button>Footer</button>}
 *         large={large}
 *     />;
 *
 * @param {React.RefObject<HTMLDivElement>} dialogRef - Ref to the dialog element
 * @param {boolean} open - If true, the dialog will be open
 * @param {boolean} withPadding - If true, the dialog will have padding
 * @param {boolean} modalMode - If true, the dialog will be not complete screen
 * @param {ReactNode} header - The header of the dialog
 * @param {ReactNode} body - The body of the dialog
 * @param {ReactNode} footer - The footer of the dialog
 * @param {boolean} large - If true, the dialog will be large
 * @param {string} label - Accessible name for the window
 * @param {Function} onClose - Invoked when Escape is pressed
 * @returns {JSX.Element}
 */
 
const Dialog = (props: Props) => {
    const {
        dialogRef,
        className,
        open,
        large,
        withPadding,
        modalMode,
        header = <></>,
        body = <></>,
        footer = <></>,
        label,
        onClose,
    } = props;
 
    const headerId = React.useId();
 
    // Escape closes, when there is something to close. A window a keyboard user cannot dismiss is a
    // trap, and this primitive backs every page on the site.
    React.useEffect(() => {
        if (!open || !onClose) return undefined;
        const onKeyDown = (event: KeyboardEvent) => {
            if (event.key === 'Escape') onClose();
        };
        window.addEventListener('keydown', onKeyDown);
        return () => window.removeEventListener('keydown', onKeyDown);
    }, [open, onClose]);
 
    return (
        /**
         * SDD-L06. This was a plain `<div>`: no role, no accessible name, no focus management, no
         * Escape — on all eight pages of the site. A screen-reader user was never told a window had
         * opened or what it was called.
         *
         * `aria-modal` only in `modalMode`. The macOS-window premise means most of these are *not*
         * modal — the Dock and menu bar stay usable behind them — and claiming otherwise would tell
         * assistive tech the rest of the page is inert when it is not. Focus is likewise not trapped,
         * for the same reason.
         *
         * `inert` when closed is the important half, and it is what the audit could not settle
         * without a runtime check: `dialog.module.css` hides a closed window with
         * `transform: scale(0)`, which is purely visual. Its buttons and links stayed in the tab order
         * and in the accessibility tree, so a keyboard user could tab into an invisible window and a
         * screen reader would read out windows that were not on screen. `inert` removes both while
         * leaving the scale transition intact, which `display: none` would not.
         */
        <div
            ref={dialogRef}
            data-testid="dialog"
            role="dialog"
            aria-modal={modalMode ? true : undefined}
            aria-label={label}
            aria-labelledby={label ? undefined : headerId}
            inert={!open}
            className={clx(
                className,
                styles.dialog,
                open ? styles.open : '',
                withPadding ? styles.padding : '',
                modalMode ? styles.modalMode : '',
                large ? styles.large : ''
            )}
        >
            <header data-testid="dialog-header" id={headerId}>
                {header}
            </header>
            {/* SDD-L05: was <main>. Layout already wraps children in one, so every page shipped
                two landmarks — invalid, and it breaks the "skip to main content" idiom because a
                screen-reader user gets two ambiguous "main" regions. On the blog the real article
                sat inside the inner one. */}
            <div className={styles.body} data-testid="dialog-body">
                {body}
            </div>
            <footer data-testid="dialog-footer">{footer}</footer>
        </div>
    );
};
 
export default Dialog;