-
Notifications
You must be signed in to change notification settings - Fork 934
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
Implement event delegation; refactor JS and tests #1836
Conversation
|
Cool! I'm not very familiar with how the USWDS JS components currently work, so I guess one question I have is, how do the virtual DOM interop concerns you have for this PR not also apply to the current state of the JS, outside of this PR? I've used third-party code with React a bit (see the lots of legacy section of the CALC React migration notes) but the main way I've evaluated whether to rewrite a dynamic component in React vs. use a native DOM implementation has largely centered around how "encapsulated" the component is in the DOM. For example, with CALC, the d3 histogram was an easy decision to make because it's easy to just take that one DOM node and tell React "let me manage this DOM node, don't mess with it". It's just a black box to React, and we can just subscribe to React's component lifecycle methods to make the native DOM changes required when the component's props are changed, remove any event listeners when the component leaves the DOM, and so on. On the other hand, things might get more complicated if there's lots of interleave between DOM and virtual DOM components. An example of this might be a native DOM-based accordion component, where the "frame" of the accordion is managed by native DOM code, but you want content inside the accordion panels that's managed by a virtual DOM. This is something I don't have much experience with, so it might be possible if you're careful, but it's also where I'd start seriously considering just re-implementing the native DOM code in React, to avoid any possibility of things exploding unexpectedly. Again, though, I don't have a lot of experience with this kind of situation, so I could be wrong. |
|
Thanks for taking a look, @toolness! I took some time this afternoon to make a little React app to test out this approach, and it appears to work. Check it out. Only the gray section is actually rendered with React (every time you click one of the two buttons), but it works the way I'd expect it to: if you open an accordion with delegated listeners then re-render the DOM with React, it doesn't change the This is the first time I've really used React, but it seems to me that users can either go this way (using the Standards JS) or re-implement the accordion interactivity in React, which seems like it might actually be pretty straightforward. Update: you can also peep the code for the React app. |
|
Okay, so this React app actually turns out to be a pretty decent test of all the components I've upgraded to use event delegation. Everything outside of the React-rendered accordion is vanilla JS and appears to work well. The tests pass in jsdom, too! Time to test out Angular? |
|
Wow! @shawnbot you've been busy. Not sure why I just now got the email on this. Having issues with local development and things...gonna tag in some teammates to see if they can help out. Having said that, it might be moot at this point. |
|
Okay, so as of c8085f8 I think the polyfill/ponyfill situation is much better: I've replaced our home-brewed DOM ready and dataset implementations with I've also renamed |
|
@joshbruce thanks! FYI, you and your team can test this branch by npm installing from git, a la: |
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 is looking real nice, @shawnbot!
Minor aside: any reason you're using a custom eslint config instead of eslint-config-airbnb-base? I personally think it would be good to use that config since it is g-frontend-approved :)
| const forEach = require('array-foreach'); | ||
| const Behavior = require('receptor/behavior'); | ||
|
|
||
| const sequence = function () { |
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 think you could use the spread operator to write this line as
const sequence = function(...seq) {and remove the following line (const seq = [].slice.call(arguments);
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.
Same, re: ES2015 and Node v6.
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.
But the es2015 babel config you're using with babelify will take care of this :)
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.
That's true, but our mocha/jsdom tests require() the modules directly.
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.
😬 Perhaps explore using jest and its babel runner: https://github.com/facebook/jest#using-babel
|
|
||
| const sequence = function () { | ||
| const seq = [].slice.call(arguments); | ||
| return function (target) { |
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.
Similarly, you could use ES2015 default args to write this as
return function (target=document.body) {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.
Same, re: ES2015 and Node v6.
| @@ -0,0 +1,31 @@ | |||
| 'use strict'; | |||
| const assign = require('object-assign'); | |||
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.
Consider using import in place of all requires. While it wouldn't really have any immediate effect, it would pave the way for tree-shaking in your bundler (either webpack2 or rollup).
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.
Could also obviously be handled later in a separate PR.
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.
Yeah, we're still using Node 6, so I was sticking to the subset of ES2015 supported natively. Let's tackle this in a future PR. :)
| }; | ||
|
|
||
| module.exports = Accordion; | ||
| module.exports = behavior({ |
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.
Along the same vein of using import, consider using export/export default. Perhaps in a separate PR.
src/js/components/banner.js
Outdated
| } | ||
| const toggleBanner = function (event) { | ||
| event.preventDefault(); | ||
| this.closest(HEADER).classList.toggle(EXPANDED_CLASS); |
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 closest polyfilled? I think it needs to be for IE. http://caniuse.com/element-closest
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.
Yeah, it's actually polyfilled by receptor via element-closest, which also polyfills matches(). (Which I guess means that we're not necessarily playing nice in that respect...)
src/js/components/search.js
Outdated
| deactivateDispatcher.off(); | ||
| } | ||
| } | ||
| const CLICK = ('ontouchstart' in document.documentElement) |
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.
consider extracting CLICK since it is used in multiple places
src/js/components/search.js
Outdated
| if (input) { | ||
| input.focus(); | ||
| } | ||
| const listener = ignore( |
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.
explanatory comment would be good here
| target.setAttribute('tabindex', -1); | ||
| })); | ||
| } else { | ||
| // throw an error? |
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.
Hrm, might want a single strategy for dealing cases where an expected element is missing. I see some other cases where an error is thrown: https://github.com/18F/web-design-standards/pull/1836/files#diff-6449513e1e61be79d366e960d8578ffbR16
I don't have a good feeling for what is "correct." On one hand, you would help developers by making problems obvious. On the other, it might break otherwise functional sites, which could be nasty for users.
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.
Yeah, I'm on the fence about this one. In other cases (e.g. with aria-controls, it doesn't feel like an error if no elements resolve because it's theoretically possible to have a button that toggles just its own state. But in this case, the link should resolve, and if it doesn't, that's at least an accessibility error.
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.
After looking at this a bit more closely, I'm going to leave it as-is for now. Throwing an error seems like the right thing to do, but that would effectively stop the link from working, and I'm a little weary that just doing link.getAttribute('href').slice(1) is naive. I'd rather users just don't get the tabindex behavior because their link is malformed (and the link still works, possibly) than preventing the link from being followed altogether.
src/js/components/skipnav.js
Outdated
| }; | ||
|
|
||
| module.exports = behavior({ | ||
| 'click': { |
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.
Why not same touchstart || click as used in other components?
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 good question :)
|
Thanks for the review, @jseppi! I agree that we should be using the guild-approved config; let's tackle that in a future PR, too. In the meantime, I'll fix up some of the other stuff you raised above. |
|
Okay @jseppi, I think I'm ready for a final pass. I addressed all of your comments above (saving more advanced ES6 features for later, normalizing all |
This removes the redundant 'title' template context and reduces the 'package' to just name and version, so as not to spam the Context pane of individual components with irrelevant data.
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 looks good to me!
|
Here we go... 🤞 |

👀 Preview on Federalist
require('uswds/src/js/components/accordion')and bundle your JavaScript with browserify or webpack, you'll need to incorporate the respective Babel plugin (babelify or babel-loader, respectively). Users of React or Angular 2 do not need to do anything special.This PR fixes #1423 by replacing all of our "direct" event listeners (added to individual elements once, on DOM ready) with delegated listeners on the
<body>that should work regardless of what happens in the rest of the DOM. I've created standalone React and Angular 1 test cases that reference the JS built by this branch, and both appear to work well. Here's the lowdown:on()andoff()methods that add and remove all of the declared listeners to the<body>on DOM ready (in the respective initializers). Our wrapped behavior provides a couple of additional niceties:init()method, that will be run by the behavior'son()method automatically (before listeners are added) to initialize any relevant elements. XXX maybe this should be calledsetup()?teardown()method, that will be run by the behavior'soff()method automatically, to clean up any temporary state.The upside of this is that if you (the user of the Standards) render parts, or even the entirety, of your page's content from an AJAX request or with another framework, you don't have to worry about event listeners being blown away and having to add them again.
Along the way, I've basically refactored all of the JS from the ground up. Here's what else happened:
start.jstouswds.js, to better match the destination filename.uswdsobject, which currently has two properties:prefixis theusaprefix used to derive CSS selectors for each component behavior.componentsis an object of named component behaviors, each of which can be added (add()/on()) and removed (remove()/off()) to any element root at runtime.components/x.js+initializers/x.jsduo of files for each component has been replaced by a single "behavior" script incomponents/x.js. In other words, component definitions are behaviors.componentsdirectory have been converted into stateless functions (toggleFieldMask(), etc.) and moved toutils.data-hide-textattribute to specify what's displayed when you toggle it, and derives a sensible default by replacing "Show" with "Hide" in the initial text content (respecting case) if there is none. Previously, these values were hard-coded in JavaScript.src/js/vendorare gone now. Those were a relic of the site, AFAICT.Unit test improvements
Along the way, I've also made some improvements to our unit test suite and removed some unnecessary dependencies:
assertmodule instead of should.ES2015
I've also taken the liberty of upgrading the component JS to ES2015 (ES6) and incorporating Babel into our builds. This makes our source JS a lot more concise, and at a fairly insignificant size penalty:
uswds.jsuswds.min.jsuswds.min.js.map