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

feat(Modal): Enable usage without wrapper, add custom Portal #571

Merged
merged 15 commits into from
Oct 2, 2016

Conversation

jeffcarbs
Copy link
Member

@jeffcarbs jeffcarbs commented Sep 30, 2016

In an issue (#553) I had suggested we rely more heavily on react-portal. After digging into it a bit more, I realized a few things:

  • It's not very actively maintained. The maintainers haven't commented on a PR in weeks if not months.
  • Didn't seem like they had an interest in adding much more. In order to make use of this component for other components (e.g. Popup) we may need more flexibility.
  • There's not a ton of code to get "Portal" functionality.

Therefore, I decided to build our own Portal loosely based on react-portal. I used their test file as a starting point to ensure we didn't lose much parity. I put it in the "addons" for now but let me know if there's a better spot for it. It has some cool features:

  • configurable closeOnDocumentClick and closeOnEscape props
  • trigger props that lets you render a node (e.g. a button) where you define the Portal so you don't need a wrapping div.
  • configurable openOnTriggerClick and openOnTriggerMouseOver props. There's no closeOnTriggerMouseLeave but I figured something like that would be useful for Popup/tooltips.
  • It's more declarative than react-portal. The open/close methods just set state and then the portal renders based on that.

I updated Modal to work with the new Portal component. There are still a few things that I need to solidify and/or figure out:

  • Modal now takes a portal prop, which are props to be passed to the Portal. It's not a shorthand because the Portal is not rendered like a regular element. The other option here would be to pass the props directly to the Modal and use something like _.pick(rest, _.keys(Portal.propTypes)) to figure out what to pass to Portal. Here's why I prefer the one prop:
    • We can theoretically allow passing null to not render the portal (e.g. if the use wants a modal but wants to handle hiding/showing it some other way).
    • It goes farther in decoupling the modal from the portal.
    • The negative is that it's slightly more verbose: <Modal portal={{ trigger: <Button>Long Modal</Button> }}> vs <Modal trigger={<Button>Long Modal</Button>}>
  • Portal is an autocontrolled component which changes the existing usage of Modal slightly.
    • Uncontrolled - you can specify a trigger and have it open on click/hover and then configure the modal to close via clickaway or escape. You can pass onOpen and onClose to the modal which will be called when the modal is opened or closed. Issue:
      • Ideally you'd be able to close the modal by interacting with some content (e.g. a button) within the modal in the uncontrolled case. Can that be possible somehow?
    • Controlled - you can specify open/close state but since onOpen and onClose are only called when the modal is actually opened/closed, there are no hooks to actually control them from the outside.
      • I'm thinking that if the modal would have closed in the uncontrolled state (e.g. press escape and have closeOnEscape set) we fire a onCloseAttempt (better name?) callback. If it would've opened, we fire the onOpenAttempt.

@codecov-io
Copy link

codecov-io commented Sep 30, 2016

Current coverage is 99.62% (diff: 100%)

Merging #571 into master will increase coverage by 0.01%

@@             master       #571   diff @@
==========================================
  Files           117        118     +1   
  Lines          1807       1862    +55   
  Methods           0          0          
  Messages          0          0          
  Branches          0          0          
==========================================
+ Hits           1800       1855    +55   
  Misses            7          7          
  Partials          0          0          

Powered by Codecov. Last update 21fc348...685c0b2

@jeffcarbs
Copy link
Member Author

@levithomason @layershifter - would appreciate some thoughts on the above, specifically the open issues 😄

@typehorror
Copy link
Contributor

Hey @jcarbo , I'm facing an issue when using the openOnTriggerFocus where the click that provides the focus might also be capture by the click listener in charge of closing the portal. We might want to make sure closing clicks on the trigger are ignored.

@levithomason
Copy link
Member

Once again, thanks much. I wanted to do exactly this ever since adding the dep 👍

Modal portal prop

We could also consider providing the Modal separately from the Portal and requiring the usage to include both the Portal and the Modal. This way, the Modal is a static component. Same with the Popup. This inspired by react-bootstrap's react-overlays. It makes it possible to use any portal configuration with any component.

I think I prefer more succinct APIs, where the Modal "just works". So, we could bundle it but provide StaticModal or something for the static one. Thinking out loud...

Open Issues

Ideally you'd be able to close the modal by interacting with some content (e.g. a button) within the modal in the uncontrolled case. Can that be possible somehow?

I think we do this for specialized modals such as the Confirm. Otherwise, it is minimal wiring for the user if they want more control. I say this as it deals with modifying children (adding click handlers). The only sane way I think we should do that is via props. This then means if the user passes us some button, we can insert that into children, but they cannot then use children. This is almost exactly what the Confirm modal does. I think any use case beyond that and they can build their own Modal implementation, for now.

I'm thinking that if the modal would have closed in the uncontrolled state (e.g. press escape and have closeOnEscape set) we fire a onCloseAttempt (better name?) callback. If it would've opened, we fire the onOpenAttempt.

I've seen request/attempt prop names around, but I think they are not inline with React. Consider the <input onChange={} />, it is fired regardless of whether or not the value will actually update. So, I think change handlers should always fire when the user tries to change the state. The value to the change handler should always be the new proposed value IMO. Whether or not that value was applied to the value prop or not (controlled/uncontrolled) makes no difference.

We had to deal with this exact issue in the Dropdown via this.onChange(). We call this every time there is a user prompted change attempt such as a tab, enter, click, etc. We manually call this as there is no guarantee trySetState will be successful.

}
}
const ModalBasicExample = () => (
<Modal portal={{ trigger: <Button>Basic Modal</Button> }} basic size='small'>
Copy link
Member

@levithomason levithomason Sep 30, 2016

Choose a reason for hiding this comment

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

Here's why I prefer the one prop:

  1. We can theoretically allow passing null to not render the portal (e.g. if the use wants a modal but wants to handle hiding/showing it some other way).
  2. It goes farther in decoupling the modal from the portal.
  3. The negative is that it's slightly more verbose: <Modal portal={{ trigger: <Button>Long Modal</Button> }}> vs <Modal trigger={<Button>Long Modal</Button>}>

You raise some good points, here are ways I think we can solve them with keeping a flat API:

  1. We could still allow portal={false} to render without the portal. Just have a separate render method for that case.
  2. We could provide separate versions of stateful components (ie Modal.Static).
  3. The plus is that we get a flat API.

<Modal portal={{ trigger: <Button>Show Modal</Button> }}>
<Modal.Header>Select a Photo</Modal.Header>
<Modal.Content image>
<Image wrapped className='medium' src='http://semantic-ui.com/images/avatar2/large/rachel.png' />
Copy link
Member

@levithomason levithomason Sep 30, 2016

Choose a reason for hiding this comment

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

While we're at it, would you mind making this size='medium'.

<Modal portal={{ trigger: <Button>Long Modal</Button> }}>
<Modal.Header>Profile Picture</Modal.Header>
<Modal.Content image>
<Image wrapped className='medium' src='http://semantic-ui.com/images/wireframe/image.png' />
Copy link
Member

Choose a reason for hiding this comment

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

Image size update here as well if you don't mind.

@@ -49,7 +49,7 @@
"classnames": "^2.1.5",
"debug": "^2.2.0",
"lodash": "^4.6.1",
"react-portal": "^2.2.1",
"react-dom": "^15.3.1",
Copy link
Member

Choose a reason for hiding this comment

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

Whoops, this should be in dev deps as it is used only for the doc site and tests. Otherwise, Stardust will ship with it baked into the lib.

Copy link
Member Author

Choose a reason for hiding this comment

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

ReactDOM is required by the Portal component:

    this.portal = ReactDOM.unstable_renderSubtreeIntoContainer(
      this,
      child,
      this.node
    )

Is there a way we can get around that?

Copy link
Member

Choose a reason for hiding this comment

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

Hm, thinking it through I don't believe we can get away without it. Though, we can at least remove it from deps and just import it. It is listed in peerDependencies with a range of supported versions. This way, users can bring their own react / react-dom and we just import it, rather than bundling it (deps).

I'm not versed on the unstable API, but I wonder why the original portal didn't use ReactDOM.render() and just treat it as it's own app. I should probably watch his talk referenced in the README, https://www.youtube.com/watch?v=z5e7kWSHWTg. :/

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, didn't realize it was already in peer-dependencies! I'll move this back.

}
}

componentDidUpdate(prevProps, prevState) { // eslint-disable-line complexity
Copy link
Member

Choose a reason for hiding this comment

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

Very few branches in this method, is // eslint-disable-line complexity still needed?


close = () => {
debug('close()')
this.trySetState({ open: false })
Copy link
Member

Choose a reason for hiding this comment

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

Sam, propose we call onClose just before trySetState here, so the user has opportunity to update any controlled open state.

if (this.node) return

this.node = document.createElement('div')
document.body.appendChild(this.node)
Copy link
Member

Choose a reason for hiding this comment

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

This was one of the issues I had with the portal, no way to configure where it renders to. How about making document.body configurable and defaulting it to document.body?. Perhaps:

<Portal mountNode={/* a DOM node */} />

Copy link
Contributor

@typehorror typehorror Sep 30, 2016

Choose a reason for hiding this comment

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

I second this request, the popup might need to appear on a different component than body (for Fluid popup). Something to consider is how to handle the case where the container where to get dynamically destroyed.

}

handleTriggerBlur = (e) => {
if (!this.props.closeOnTriggerBlur) return
Copy link
Member

@levithomason levithomason Sep 30, 2016

Choose a reason for hiding this comment

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

Since these are all overwriting the trigger props when cloned in the render, we need to also call the trigger handler if it exists, before we exit. Probably add some tests that ensure this is the case.

Lodash's _.invoke handles this gracefully:

handleTriggerBlur = (e) => {
  const { trigger, closeOnTriggerBlur } = this.props

  _.invoke(trigger, 'onBlur', e)
  if (!closeOnTriggerBlur) return
  // ...

this.portal = null

const { onClose } = this.props
if (onClose) onClose()
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we should only call event handlers if they are triggered by direct user interactions, but not by programmatic changes. This is consistent with vanilla HTML and vanilla React handling of change events:

http://codepen.io/levithomason/full/YGAXEb/

Copy link
Contributor

@typehorror typehorror Sep 30, 2016

Choose a reason for hiding this comment

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

Is there anyway we could consistently pass the event (if any) responsible for closing and opening to onClose and onOpen callback ?

Copy link
Member

@levithomason levithomason Oct 1, 2016

Choose a reason for hiding this comment

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

Yes sir, this is another reason to only use user events to trigger handlers. Programmatic changes do not have reliable events to pass back.

We should call the corresponding trigger event handler in handleTriggerClick handleTriggerFocus handleTriggerMouseLeave and handleTriggerMouseOver and pass the event. Noted above in this comment.

We should pass the event from each of those to the open/close method as well so that it can be passed to the onOpen and onClose callbacks. This way, all callbacks get all the events possible.

}

handleShow = () => {
debug('handleShow()')
handleOpen = () => {
Copy link
Member

Choose a reason for hiding this comment

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

👍 thanks for the rename, open/close makes much more sense.

@jeffcarbs
Copy link
Member Author

jeffcarbs commented Oct 1, 2016

Ok made some updates:

  • onOpen/onClose now fire when the portal is initially opened/closed AND whenever anything responsible for
  • fixed an issue with openOnTriggerFocus where clicking the trigger would immediately close it (cc @Debrice)
  • invoke the trigger's event handlers (e.g. onClick) within the callbacks we're hijacking.

Still a few left to go:

  • Pass the triggering event as an argument to onOpen/onClose
  • Custom mountNode prop.
  • Clean up portal prop handling (see below)
  • Deal with Modal handleOpen/handleClose (see below)

Questions:

  • For mountNode, should that be a DOM node? If so, is there a propType for that? Otherwise, should it be a selector? I'm kinda leaning DOM node to keep things more sane.
  • Where did we land with portal props? Just pass them directly to the Modal and have a method to pick off the relevant ones (kinda like we do for inputProps inside of the Input component)?
  • Related to the portal props, I think coupling the modal + portal is the way to go for now. We can decide on some sort of Modal.Staticconcept on a separate PR if that's cool. Esp given the Popup is relying on this, I don't want this to get held up too much.
  • We're now calling onOpen and onClose always, even when nothing happened (in the controlled scenario). There's still code within Modal that needs to be called to add/remove the dimmer-related classed to the body (see https://github.com/TechnologyAdvice/stardust/pull/571/files#diff-e762b5cc3074c8997386b6de83cac7fbR102). That's currently relying on those callbacks as well, so I'm not sure how to have those only run when the modal is shown/removed. One thought is onMount/onUnmount callbacks that are called within mountPortal and unmountPortal since those are only called when the portal is actually opened/closed.

Thoughts?

@jeffcarbs
Copy link
Member Author

Ok, wound up going with the onMount and onUnmount which wound up making more sense I think:

  • onOpen is called on every event that would trigger an open based on your props (e.g. openOnTriggerFocus)
  • onClose is called on every event that would trigger a close based on your props (e.g. closeOnEscape)
  • onMount is called when the portal mounts onto the DOM
  • onUnmount is called when the portal unmounts from the DOM

The Modal needs to listen for the mount/unmount to handle adding/removing dimmer classes.

The one thing left to do is figure out how to handle portal props. I think I'm going to just remove the portal prop and just pass-through any portal-related props from the modal to the portal.

@jeffcarbs
Copy link
Member Author

I fixed up all the specs and examples and the API feels pretty good. Passing the props directly to Modal and having the props passed-through to Portal is definitely the way to go. So much cleaner than passing an object as a portal prop.

@levithomason - I think this is ready for final review 🙌

@levithomason
Copy link
Member

Made a few trivial cleanup commits and rebased/fix conflicts with latest master. Will merge on pass. I'll be out, so will release later tonight!

@levithomason levithomason merged commit 8e579b1 into master Oct 2, 2016
@levithomason levithomason deleted the feature/modal branch October 2, 2016 01:31
@levithomason
Copy link
Member

... Or I'll just do it now. Released in stardust@0.50.0

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