Skip to content

Commit

Permalink
feat(react): add dialog component (#9496)
Browse files Browse the repository at this point in the history
* chore: check-in work

* chore: check-in work

* chore: check-in work

* refactor(react): update Dialog and pull-out helper components

* chore(react): hide dialog story

* Delete FocusScope-test.js
  • Loading branch information
joshblack committed Sep 3, 2021
1 parent 3dfabc9 commit 3b5a1da
Show file tree
Hide file tree
Showing 14 changed files with 707 additions and 0 deletions.
Binary file not shown.
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"lodash.throttle": "^4.1.1",
"react-is": "^16.8.6",
"use-resize-observer": "^6.0.0",
"wicg-inert": "^3.1.1",
"window-or-global": "^1.0.1"
},
"devDependencies": {
Expand Down
152 changes: 152 additions & 0 deletions packages/react/src/components/Dialog/Dialog-story.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Copyright IBM Corp. 2016, 2018
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import * as React from 'react';
import { FocusScope } from '../FocusScope';
import { Dialog } from '../Dialog';
import { useId } from '../../internal/useId';
import { Portal } from '../Portal';

export default {
title: 'Experimental/unstable_Dialog',
includeStories: [],
};

export const Default = () => {
function DemoComponent() {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);

return (
<div
style={{
border: '1px solid black',
background: 'rgba(0, 0, 0, 0.1)',
padding: '1rem',
}}>
<button
type="button"
onClick={() => {
setOpen(true);
}}>
Open
</button>
{open ? (
<FocusScope>
<div>
<p>
Elit hic at labore culpa itaque fugiat. Consequuntur iure autem
autem officiis dolores facilis nulla earum! Neque quia nemo
sequi assumenda ratione officia Voluptate beatae eligendi
placeat nemo laborum, ratione.
</p>
<DemoComponent />
<button
ref={ref}
type="button"
onClick={() => {
setOpen(false);
}}>
Close
</button>
</div>
</FocusScope>
) : null}
</div>
);
}
return (
<>
<DemoComponent />
<button type="button">Hello</button>
</>
);
};

export const DialogExample = () => {
function Example() {
const [open, setOpen] = React.useState(false);
const id = useId();

return (
<div>
<div>
<button type="button">First</button>
</div>
<button
type="button"
onClick={() => {
setOpen(true);
}}>
Open
</button>
{open ? (
<Portal
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 9999,
}}>
<FullPage />
<Dialog
aria-labelledby={id}
onDismiss={() => {
setOpen(false);
}}
style={{
position: 'relative',
zIndex: 9999,
padding: '1rem',
background: 'white',
}}>
<div>
<span id={id}>Hello</span>
</div>
<div>
<Example />
</div>
<button
type="button"
onClick={() => {
setOpen(false);
}}>
Close
</button>
</Dialog>
</Portal>
) : null}

<div>
<button type="button">Last</button>
</div>
</div>
);
}

return <Example />;
};

const FullPage = React.forwardRef(function FullPage(props, ref) {
return (
<div
ref={ref}
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
transform: 'translateZ(0)',
background: 'rgba(0, 0, 0, 0.5)',
}}
{...props}
/>
);
});
153 changes: 153 additions & 0 deletions packages/react/src/components/Dialog/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Copyright IBM Corp. 2016, 2018
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import 'wicg-inert';
import PropTypes from 'prop-types';
import React, { useEffect, useRef } from 'react';
import { FocusScope } from '../FocusScope';
import { useMergedRefs } from '../../internal/useMergedRefs';
import { useSavedCallback } from '../../internal/useSavedCallback';
import { match, keys } from '../../internal/keyboard';

/**
* @see https://www.tpgi.com/the-current-state-of-modal-dialog-accessibility/
*/
const Dialog = React.forwardRef(function Dialog(props, forwardRef) {
const { 'aria-labelledby': labelledBy, children, onDismiss, ...rest } = props;
const dialogRef = useRef(null);
const ref = useMergedRefs([dialogRef, forwardRef]);
const savedOnDismiss = useSavedCallback(onDismiss);

function onKeyDown(event) {
if (match(event, keys.Escape)) {
event.stopPropagation();
savedOnDismiss();
}
}

useEffect(() => {
const changes = hide(document.body, dialogRef.current);
return () => {
show(changes);
};
}, []);

return (
<FocusScope
{...rest}
aria-labelledby={labelledBy}
aria-modal="true"
initialFocusRef={dialogRef}
onKeyDown={onKeyDown}
ref={ref}
role="dialog"
tabIndex="-1">
{children}
</FocusScope>
);
});

Dialog.propTypes = {
/**
* Provide the associated element that labels the Dialog
*/
'aria-labelledby': PropTypes.string.isRequired,

/**
* Provide children to be rendered inside of the Dialog
*/
children: PropTypes.node,

/**
* Provide a handler that is called when the Dialog is requesting to be closed
*/
onDismiss: PropTypes.func.isRequired,
};

if (__DEV__) {
Dialog.displayName = 'Dialog';
}

function hide(root, dialog) {
const changes = [];
const queue = Array.from(root.childNodes);

while (queue.length !== 0) {
const node = queue.shift();

if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}

// If a node is the dialog, do nothing
if (node === dialog) {
continue;
}

// If a tree contains our dialog, traverse its children
if (node.contains(dialog)) {
queue.push(...Array.from(node.childNodes));
continue;
}

// If a node is a bumper, do nothing
if (
node.hasAttribute('data-carbon-focus-scope') &&
(dialog.previousSibling === node || dialog.nextSibling === node)
) {
continue;
}

if (node.getAttribute('aria-hidden') === 'true') {
continue;
}

if (node.hasAttribute('inert')) {
continue;
}

if (node.getAttribute('aria-hidden') === 'false') {
node.setAttribute('aria-hidden', 'true');
node.setAttribute('inert', '');
changes.push({
node,
attributes: {
'aria-hidden': 'false',
},
});
continue;
}

// Otherwise, set it to inert and set aria-hidden to true
node.setAttribute('aria-hidden', 'true');
node.setAttribute('inert', '');

changes.push({
node,
});
}

return changes;
}

function show(changes) {
changes.forEach(({ node, attributes }) => {
node.removeAttribute('inert');
// This mutation needs to be asynchronous to allow the polyfill time to
// observe the change and allow mutations to occur
// https://github.com/WICG/inert#performance-and-gotchas
setTimeout(() => {
if (attributes && attributes['aria-hidden']) {
node.setAttribute('aria-hidden', attributes['aria-hidden']);
} else {
node.removeAttribute('aria-hidden');
}
}, 0);
});
}

export { Dialog };
Loading

0 comments on commit 3b5a1da

Please sign in to comment.