-
Notifications
You must be signed in to change notification settings - Fork 4k
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
Conversation
46673b0
to
4a495db
Compare
Current coverage is 99.62% (diff: 100%)@@ 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
|
@levithomason @layershifter - would appreciate some thoughts on the above, specifically the open issues 😄 |
Hey @jcarbo , I'm facing an issue when using the |
Once again, thanks much. I wanted to do exactly this ever since adding the dep 👍 Modal portal propWe 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 Open Issues
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've seen We had to deal with this exact issue in the Dropdown via |
} | ||
} | ||
const ModalBasicExample = () => ( | ||
<Modal portal={{ trigger: <Button>Basic Modal</Button> }} basic size='small'> |
There was a problem hiding this comment.
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:
- 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>}>
You raise some good points, here are ways I think we can solve them with keeping a flat API:
- We could still allow
portal={false}
to render without the portal. Just have a separate render method for that case. - We could provide separate versions of stateful components (ie
Modal.Static
). - 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' /> |
There was a problem hiding this comment.
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' /> |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. :/
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 }) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 */} />
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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 = () => { |
There was a problem hiding this comment.
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.
Ok made some updates:
Still a few left to go:
Questions:
Thoughts? |
Ok, wound up going with the
The The one thing left to do is figure out how to handle portal props. I think I'm going to just remove the |
I fixed up all the specs and examples and the API feels pretty good. Passing the props directly to @levithomason - I think this is ready for final review 🙌 |
2ce70e8
to
685c0b2
Compare
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! |
... Or I'll just do it now. Released in |
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:Popup
) we may need more flexibility.Therefore, I decided to build our own
Portal
loosely based onreact-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:closeOnDocumentClick
andcloseOnEscape
propstrigger
props that lets you render a node (e.g. a button) where you define the Portal so you don't need a wrapping div.openOnTriggerClick
andopenOnTriggerMouseOver
props. There's nocloseOnTriggerMouseLeave
but I figured something like that would be useful forPopup
/tooltips
.react-portal
. Theopen
/close
methods just set state and then the portal renders based on that.I updated
Modal
to work with the newPortal
component. There are still a few things that I need to solidify and/or figure out:Modal
now takes aportal
prop, which are props to be passed to thePortal
. It's not a shorthand because thePortal
is not rendered like a regular element. The other option here would be to pass the props directly to theModal
and use something like_.pick(rest, _.keys(Portal.propTypes))
to figure out what to pass toPortal
. Here's why I prefer the one prop:<Modal portal={{ trigger: <Button>Long Modal</Button> }}>
vs<Modal trigger={<Button>Long Modal</Button>}>
Portal
is an autocontrolled component which changes the existing usage ofModal
slightly.onOpen
andonClose
to the modal which will be called when the modal is opened or closed. Issue:onOpen
andonClose
are only called when the modal is actually opened/closed, there are no hooks to actually control them from the outside.closeOnEscape
set) we fire aonCloseAttempt
(better name?) callback. If it would've opened, we fire theonOpenAttempt
.