Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/1067 focus trap initialfocus #1099

Merged

Conversation

chandlerprall
Copy link
Contributor

Closes #1067

  • Adds the initialFocus prop to EuiModal and EuiPopver
  • EuiModal forwards the prop through to FocusTrap
  • EuiPopover already does things around focusing, so the initialFocus functionality is replicated in the popover component instead of passing it to FocusTrap.
  • Updates a modal & popover example to use the new prop
  • Fix EuiSwitch to actually use its id prop

Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this in browser and it works great! Code LGTM too except for a couple a comments/questions.

@@ -33,11 +33,12 @@ export class EuiOverlayMask extends Component {
}
this.overlayMaskNode.setAttribute(key, rest[key]);
});

document.body.appendChild(this.overlayMaskNode);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, why did you move this into the constructor? Does this merit a comment to explain why it's better than being in componentDidMount?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this React DOM tree:

<a>
  <b>
    <c>

React will mount the tree into the document and then call componentDidMount in the order c -> b -> a. FocusTrap activates the focus in componentDidMount, but if the target element is not in the document an error is thrown. To ensure the target element is part of the document before FocusTrap's componentDidMount is triggered, the overlayMaskNode must be appended to the document in the constructor, as its componentDidMount happens after FocusTrap's.

Because of this, to maintain & allow natural assumptions we build about React's lifecycle, it's best practice to always best to add the custom DOM elements in the constructor.

@@ -193,7 +208,9 @@ export class EuiPopover extends Component {
}, 250);
}

this.updateFocus();
this.updateFocus(
Copy link
Contributor

@cjcenizal cjcenizal Aug 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like updateFocus doesn't accept any arguments, so is this argument necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! It did something at one point during my refactoring :)

@@ -32,6 +33,7 @@ export class EuiModal extends Component {
<FocusTrap
focusTrapOptions={{
fallbackFocus: () => this.modal,
initialFocus: initialFocus,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit but I think you could just leave off the assignment:

focusTrapOptions={{
  fallbackFocus: () => this.modal,
  initialFocus,
}}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that should be an eslint rule...

@@ -65,4 +67,10 @@ EuiModal.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
onClose: PropTypes.func.isRequired,
/** specifies what element should initially have focus; Can be a DOM node, or a selector string (which will be passed to document.querySelector() to find the DOM node), or a function that returns a DOM node. */
initialFocus: PropTypes.oneOfType([
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General question; do you always type check properties you don't actually use? You're not using that prop, so you don't really care what it is. You're just passing this into FocusTrap, and I assume this check matches that one, so it seems redundant. At least that's what I've been doing, but I'm open to arguments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of reasons:

  1. enforces the interface as the boundary; yes, the prop is directly passed to FocusTrap but that's a detail I'd like to obscure
  2. if I'm building something with EuiModal and want to know what this prop can be, I want to open up modal.js and see the answer right away without digging through any more code
  3. our doc's find the proptypes to list based on this, specifying this prop any other way interferes with the output

@@ -76,6 +76,13 @@ const DEFAULT_POPOVER_STYLES = {

const GROUP_NUMERIC = /^([\d.]+)/;

function getElementFromInitialFocus(initialFocus) {
const initialFocusType = typeof initialFocus;
if (initialFocusType === 'string') return document.querySelector(initialFocus);
Copy link
Contributor

@w33ble w33ble Aug 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document.querySelector will look on the entire page for the element to focus. You want to constrain this to just the contents of the popover, otherwise it doesn't work. Tested locally and confirmed.

Copy link
Contributor

@w33ble w33ble Aug 8, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bah! This is how the upstream library works too, it just happened that the thing I tried in the modal happened to be the only item.

I still can't get things to focus correctly in the popover, but document.querySelector isn't why...

Copy link
Contributor

@w33ble w33ble Aug 9, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't figure out why, but popover focus does not work. The props look right, the node exists, it says it's calling .focus(). I tried putting a setTimeout on the focus call since that's what the FocusTrap component does, but that didn't help.

screenshot 2018-08-08 17 02 24

screenshot 2018-08-08 17 04 48

...but the node definitely does not get focused:

screenshot 2018-08-08 17 02 35

It should look like this when that input is focused:

screenshot 2018-08-08 17 05 28

Copy link
Contributor Author

@chandlerprall chandlerprall Aug 9, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bah! This is how the upstream library works too, it just happened that the thing I tried in the modal happened to be the only item.

Yep, that's why :) I agree with your thoughts on this, I wish the upstream library didn't search the entire DOM.

Is your element display: none or visibility: hidden at the time of focus, or otherwise hidden (browser can't focus non-intractable elements)? To debug these issues when working on the PR I did the following in dev console:

const origfocus = HTMLElement.prototype.focus;
HTMLElement.prototype.focus = function() {
  const style = window.getComputedStyle(this);
  console.log(this); // what element is being focused
  console.log(style.display, style.visibility);
  origfocus.call(this);
};

Adding a debugger in that function also helps to inspect the rest of the DOM at time of focus, or do other tests/inspections on the target element.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is your element display: none or visibility: hidden at the time of focus, or otherwise hidden

Nope, it's just an input in the popover content. We wrap popover, but primarily only so we can set ownFocus to true by default. We do use a render prop, so maybe that is causing some timing issue... I'll try agian without the wrapper, the issue may not be here.

@w33ble
Copy link
Contributor

w33ble commented Aug 10, 2018

@chandlerprall here's a simple demo component I made, and the input in the popover does not get focused:

import { EuiFlexItem, EuiButton, EuiPopover } from '@elastic/eui';

class PopoverDemo extends React.PureComponent {
  state = {
    isOpen: false,
  };

  onClick = () => {
    this.setState(state => ({ isOpen: !state.isOpen }));
  };

  onClose = () => {
    this.setState(() => ({ isOpen: false }));
  };

  renderControl = () => {
    return <EuiButton onClick={this.onClick}>Show popover</EuiButton>;
  };

  render() {
    return (
      <EuiFlexItem grow={false}>
        <EuiPopover
          id="popover"
          button={this.renderControl()}
          isOpen={this.state.isOpen}
          closePopover={this.onClose}
          initialFocus="#popover-demo-input-1234"
          ownFocus
        >
          <div>Popover content</div>
          <div>
            Input: <input id="popover-demo-input-1234" />
          </div>
          <EuiButton onClick={this.onClick}>Close popover</EuiButton>
        </EuiPopover>
      </EuiFlexItem>
    );
  }
}

No dice.

aug-10-2018 09-59-18

Am I doing something wrong?

@chandlerprall
Copy link
Contributor Author

@w33ble I copied & pasted your demo component into the EUI Popover example and it worked as expected, focusing the input box when the popover opened. Can you confirm you're using the updates from this PR?

@chandlerprall
Copy link
Contributor Author

I was able to reproduce the still-problematic case of the popover not focusing correctly in Firefox.

@chandlerprall
Copy link
Contributor Author

@w33ble updated to fix the popover initial focus race condition, please test again

Copy link
Contributor

@w33ble w33ble left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't get this to link correctly now that canvas is in the Kibana repo, so I can't really test this out. Fingers crossed you got it right!

@snide
Copy link
Contributor

snide commented Sep 18, 2018

@w33ble don't forget to link within xpack as well (might have been the issue)

@w33ble
Copy link
Contributor

w33ble commented Sep 18, 2018

@snide Yeah, I linked in both places, but the canvas plugin build step always fails because it can't resolve babel plugins... I have no idea why it doesn't work when I link EUI, but does work normally. I spent most of my morning trying to fix it to no avail.

@chandlerprall
Copy link
Contributor Author

Can confirm that linking EUI breaks the canvas build, we'll need to look into that at some point. To test, I built EUI locally and manually copied src and lib directories into kibana & x-pack, and then ran canvas's yarn start. I've verified that I cannot reproduce the issue in Firefox (or Chrome) with the code from this PR. Thanks for helping debug this @w33ble !

@chandlerprall chandlerprall merged commit fcc2fec into elastic:master Sep 20, 2018
@chandlerprall chandlerprall deleted the feature/1067-focus-trap-initialfocus branch September 20, 2018 16:14
@snide snide mentioned this pull request Oct 3, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants