Skip to content
This repository has been archived by the owner on Oct 19, 2021. It is now read-only.

feat(ComposedModal): support selectorPrimaryFocus prop #1634

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
48 changes: 46 additions & 2 deletions src/components/ComposedModal/ComposedModal-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import ComposedModal, {
import Button from '../Button';

const props = {
composedModal: () => ({
open: boolean('Open (open in <ComposedModal>)', true),
composedModal: (includeOpen = true) => ({
open: includeOpen ? boolean('Open (open in <ComposedModal>)', true) : null,
onKeyDown: action('onKeyDown'),
danger: boolean('Danger mode (danger)', false),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
'[data-modal-primary-focus]'
),
}),
modalHeader: () => ({
label: text('Optional Label (label in <ModalHeader>)', 'Optional Label'),
Expand Down Expand Up @@ -102,4 +106,44 @@ storiesOf('ComposedModal', module)
`,
},
}
)
.add(
'Example usage with trigger button',
() => {
class ComposedModalExample extends React.Component {
state = { open: false };
toggleModal = open => this.setState({ open });
render() {
const { open } = this.state;
return (
<>
<Button onClick={() => this.toggleModal(true)}>
Launch composed modal
</Button>
<ComposedModal
{...props.composedModal()}
open={open}
onClose={() => this.toggleModal(false)}>
<ModalHeader {...props.modalHeader()} />
<ModalBody>
<p className="bx--modal-content__text">
Please see ModalWrapper for more examples and demo of the
functionality.
</p>
</ModalBody>
<ModalFooter {...props.modalFooter()} />
</ComposedModal>
</>
);
}
}
return <ComposedModalExample />;
},
{
info: {
text: `
An example ComposedModal with a trigger button
`,
},
}
);
25 changes: 24 additions & 1 deletion src/components/ComposedModal/ComposedModal-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ describe('<ComposedModal />', () => {
});

it('changes the open state upon change in props', () => {
const wrapper = shallow(<ComposedModal open />);
const wrapper = mount(<ComposedModal open />);
expect(wrapper.state().open).toEqual(true);
wrapper.setProps({ open: false });
expect(wrapper.state().open).toEqual(false);
Expand Down Expand Up @@ -186,4 +186,27 @@ describe('<ComposedModal />', () => {
button.simulate('click');
expect(wrapper.state().open).toEqual(true);
});

it('should focus on the primary actionable button in ModalFooter by default', () => {
mount(
<ComposedModal open>
<ModalFooter primaryButtonText="Save" />
</ComposedModal>
);
expect(
document.activeElement.classList.contains('bx--btn--primary')
).toEqual(true);
});

it('should focus on the element that matches selectorPrimaryFocus', () => {
mount(
<ComposedModal open selectorPrimaryFocus=".bx--modal-close">
<ModalHeader label="Optional Label" title="Example" />
<ModalFooter primaryButtonText="Save" />
</ComposedModal>
);
expect(
document.activeElement.classList.contains('bx--modal-close')
).toEqual(true);
});
});
159 changes: 134 additions & 25 deletions src/components/ComposedModal/ComposedModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@ import classNames from 'classnames';
import { settings } from 'carbon-components';

const { prefix } = settings;
const matchesFuncName =
typeof Element !== 'undefined' &&
['matches', 'webkitMatchesSelector', 'msMatchesSelector'].filter(
name => typeof Element.prototype[name] === 'function'
)[0];

export default class ComposedModal extends Component {
state = {};

static defaultProps = {
onKeyDown: () => {},
selectorPrimaryFocus: '[data-modal-primary-focus]',
};

outerModal = React.createRef();
innerModal = React.createRef();
button = React.createRef();

static propTypes = {
/**
Expand All @@ -40,22 +47,19 @@ export default class ComposedModal extends Component {
* `onKeyDown` events that do not close the modal
*/
onKeyDown: PropTypes.func,
};

handleKeyDown = evt => {
if (evt.which === 27) {
this.closeModal();
}
/**
* Specify whether the Modal is currently open
*/
open: PropTypes.bool,

this.props.onKeyDown(evt);
/**
* Specify a CSS selector that matches the DOM element that should be
* focused when the Modal opens
*/
selectorPrimaryFocus: PropTypes.string,
};

componentDidMount() {
if (this.outerModal.current) {
this.outerModal.current.focus();
}
}

static getDerivedStateFromProps({ open }, state) {
const { prevOpen } = state;
return prevOpen === open
Expand All @@ -66,13 +70,41 @@ export default class ComposedModal extends Component {
};
}

closeModal = () => {
const { onClose } = this.props;
if (!onClose || onClose() !== false) {
this.setState({
open: false,
});
elementOrParentIsFloatingMenu = target => {
const {
selectorsFloatingMenus = [
`.${prefix}--overflow-menu-options`,
`.${prefix}--tooltip`,
'.flatpickr-calendar',
],
} = this.props;
if (target && typeof target.closest === 'function') {
return selectorsFloatingMenus.some(selector => target.closest(selector));
}

// Alternative if closest does not exist.
while (target) {
if (typeof target[matchesFuncName] === 'function') {
if (
selectorsFloatingMenus.some(selector =>
target[matchesFuncName](selector)
)
) {
return true;
}
}
target = target.parentNode;
}
return false;
};

handleKeyDown = evt => {
// Esc key
if (evt.which === 27) {
this.closeModal();
}

this.props.onKeyDown(evt);
};

handleClick = evt => {
Expand All @@ -84,13 +116,81 @@ export default class ComposedModal extends Component {
}
};

focusModal = () => {
if (this.outerModal.current) {
this.outerModal.current.focus();
}
};

handleBlur = evt => {
// Keyboard trap
if (
this.innerModal.current &&
this.props.open &&
evt.relatedTarget &&
!this.innerModal.current.contains(evt.relatedTarget) &&
!this.elementOrParentIsFloatingMenu(evt.relatedTarget)
) {
this.focusModal();
}
};

componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.beingOpen = true;
} else if (prevProps.open && !this.props.open) {
this.beingOpen = false;
}
}

focusButton = focusContainerElement => {
const primaryFocusElement = focusContainerElement.querySelector(
this.props.selectorPrimaryFocus
);
if (primaryFocusElement) {
primaryFocusElement.focus();
return;
}
if (this.button.current) {
this.button.current.focus();
}
};

componentDidMount() {
if (!this.props.open) {
return;
}
this.focusButton(this.innerModal.current);
}

handleTransitionEnd = evt => {
if (
this.outerModal.current.offsetWidth &&
this.outerModal.current.offsetHeight &&
this.beingOpen
) {
this.focusButton(evt.currentTarget);
this.beingOpen = false;
}
};

closeModal = () => {
const { onClose } = this.props;
if (!onClose || onClose() !== false) {
this.setState({
open: false,
});
}
};

render() {
const { open } = this.state;
const {
className,
containerClassName,
children,
danger,
selectorPrimaryFocus, // eslint-disable-line
...other
} = this.props;

Expand All @@ -107,22 +207,30 @@ export default class ComposedModal extends Component {
});

const childrenWithProps = React.Children.toArray(children).map(child => {
if (child.type === ModalHeader || child.type === ModalFooter) {
return React.cloneElement(child, {
closeModal: this.closeModal,
});
switch (child.type) {
case ModalHeader:
return React.cloneElement(child, {
closeModal: this.closeModal,
});
case ModalFooter:
return React.cloneElement(child, {
closeModal: this.closeModal,
inputref: this.button,
});
default:
return child;
}

return child;
});

return (
<div
{...other}
role="presentation"
ref={this.outerModal}
onBlur={this.handleBlur}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
onTransitionEnd={open ? this.handleTransitionEnd : undefined}
className={modalClass}
tabIndex={-1}>
<div ref={this.innerModal} className={containerClass}>
Expand Down Expand Up @@ -401,7 +509,8 @@ export class ModalFooter extends Component {
onClick={onRequestSubmit}
className={primaryClass}
disabled={primaryButtonDisabled}
kind={danger ? 'danger--primary' : 'primary'}>
kind={danger ? 'danger--primary' : 'primary'}
inputref={this.props.inputref}>
{primaryButtonText}
</Button>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ exports[`<ComposedModal /> renders 1`] = `
<ComposedModal
onKeyDown={[Function]}
open={true}
selectorPrimaryFocus="[data-modal-primary-focus]"
>
<div
className="bx--modal is-visible"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
onTransitionEnd={[Function]}
open={true}
role="presentation"
tabIndex={-1}
Expand Down
16 changes: 8 additions & 8 deletions src/components/Modal/Modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ export default class Modal extends Component {
selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string),

/**
* Specify a CSS selector that matches the DOM element that should be focused
* when the Modal becomes open
* Specify a CSS selector that matches the DOM element that should
* be focused when the Modal opens
*/
selectorPrimaryFocus: PropTypes.string,
};
Expand Down Expand Up @@ -181,6 +181,12 @@ export default class Modal extends Component {
}
};

focusModal = () => {
if (this.outerModal.current) {
this.outerModal.current.focus();
}
};

handleBlur = evt => {
// Keyboard trap
if (
Expand All @@ -202,12 +208,6 @@ export default class Modal extends Component {
}
}

focusModal = () => {
if (this.outerModal.current) {
this.outerModal.current.focus();
}
};

focusButton = focusContainerElement => {
const primaryFocusElement = focusContainerElement.querySelector(
this.props.selectorPrimaryFocus
Expand Down