Skip to content

Conversation

@devongovett
Copy link
Contributor

This adds a within prop to the Focus event component, which makes events fire when descendants are focused and blurred in addition to the immediate child. This allows additional flexibility when building more complex components. The within name aligns with the CSS :focus-within pseudo class.

For example, in a Date Picker component I'm working on, the focus ring CSS class is added to an outer element when one of the descendent elements is selected. Doing it with one Focus component wrapper is easier than wrapping each individual focusable element, propagating the focus state (and focus visible state) upwards and maintaining state manually on the common parent.

Screen Shot 2019-06-09 at 3 42 42 PM

Screen Shot 2019-06-09 at 3 43 52 PM

As you can see in the DOM structure above, the div with role="combobox" gets the focus ring class applied to it, but the individual divs with role="spinbutton" are actually focusable. This PR allows me to wrap the entire combobox in a Focus component with the within prop set in order to apply the focus ring class rather than each individual spinbutton.

@sizebot
Copy link

sizebot commented Jun 9, 2019

Details of bundled changes.

Comparing: e9d0a3f...d438ac9

react-events

File Filesize Diff Gzip Diff Prev Size Current Size Prev Gzip Current Gzip ENV
react-events-drag.development.js 0.0% 0.0% 83.53 KB 83.53 KB 21.34 KB 21.34 KB UMD_DEV
react-events-hover.production.min.js 0.0% 🔺+0.2% 3.78 KB 3.78 KB 1.39 KB 1.39 KB UMD_PROD
ReactEventsFocus-dev.js +28.7% +12.0% 7.16 KB 9.21 KB 1.86 KB 2.08 KB FB_WWW_DEV
react-events-drag.production.min.js 0.0% -0.0% 10.26 KB 10.26 KB 3.86 KB 3.86 KB UMD_PROD
react-events.development.js 0.0% +0.1% 1.31 KB 1.31 KB 706 B 707 B UMD_DEV
react-events-focus-scope.development.js 0.0% +0.1% 4.23 KB 4.23 KB 1.31 KB 1.31 KB UMD_DEV
react-events.production.min.js 0.0% 🔺+0.2% 682 B 682 B 430 B 431 B UMD_PROD
react-events-focus-scope.production.min.js 0.0% 🔺+0.2% 1.8 KB 1.8 KB 946 B 948 B UMD_PROD
react-events.development.js 0.0% +0.2% 1.12 KB 1.12 KB 644 B 645 B NODE_DEV
react-events-focus-scope.development.js 0.0% +0.2% 4.03 KB 4.03 KB 1.26 KB 1.26 KB NODE_DEV
react-events.production.min.js 0.0% 🔺+0.3% 512 B 512 B 353 B 354 B NODE_PROD
react-events-focus-scope.production.min.js 0.0% 🔺+0.2% 1.6 KB 1.6 KB 855 B 857 B NODE_PROD
react-events-focus.development.js +27.1% +12.1% 7.23 KB 9.19 KB 1.87 KB 2.1 KB UMD_DEV
react-events-scroll.development.js 0.0% +0.2% 3.95 KB 3.95 KB 1.25 KB 1.25 KB UMD_DEV
react-events-focus.production.min.js 🔺+28.7% 🔺+12.6% 3.05 KB 3.93 KB 1.17 KB 1.32 KB UMD_PROD
react-events-scroll.production.min.js 0.0% 🔺+0.2% 1.74 KB 1.74 KB 870 B 872 B UMD_PROD
react-events-focus.development.js +27.9% +12.5% 7.04 KB 9 KB 1.82 KB 2.05 KB NODE_DEV
react-events-scroll.development.js 0.0% +0.2% 3.76 KB 3.76 KB 1.19 KB 1.2 KB NODE_DEV
react-events-focus.production.min.js 🔺+30.6% 🔺+13.8% 2.87 KB 3.75 KB 1.09 KB 1.24 KB NODE_PROD
react-events-scroll.production.min.js 0.0% 🔺+0.1% 1.53 KB 1.53 KB 774 B 775 B NODE_PROD
ReactEventsFocus-prod.js 🔺+32.1% 🔺+15.1% 6.15 KB 8.12 KB 1.44 KB 1.66 KB FB_WWW_PROD
react-events-hover.development.js 0.0% +0.1% 8.64 KB 8.64 KB 2.08 KB 2.09 KB NODE_DEV
react-events-drag.development.js 0.0% 0.0% 7.71 KB 7.71 KB 2.36 KB 2.36 KB NODE_DEV
react-events-hover.production.min.js 0.0% 🔺+0.2% 3.59 KB 3.59 KB 1.32 KB 1.32 KB NODE_PROD
react-events-drag.production.min.js 0.0% 🔺+0.1% 3.15 KB 3.15 KB 1.45 KB 1.45 KB NODE_PROD
react-events-press.development.js 0.0% 0.0% 23.55 KB 23.55 KB 5.43 KB 5.43 KB UMD_DEV
react-events-swipe.development.js 0.0% +0.2% 6.24 KB 6.24 KB 1.75 KB 1.76 KB UMD_DEV
react-events-press.production.min.js 0.0% 🔺+0.1% 8.34 KB 8.34 KB 3.03 KB 3.03 KB UMD_PROD
react-events-swipe.production.min.js 0.0% 🔺+0.2% 2.61 KB 2.61 KB 1.18 KB 1.19 KB UMD_PROD
react-events-press.development.js 0.0% +0.1% 23.36 KB 23.36 KB 5.38 KB 5.38 KB NODE_DEV
react-events-swipe.development.js 0.0% +0.1% 6.05 KB 6.05 KB 1.71 KB 1.71 KB NODE_DEV
react-events-press.production.min.js 0.0% 🔺+0.1% 8.15 KB 8.15 KB 2.97 KB 2.97 KB NODE_PROD
react-events-swipe.production.min.js 0.0% 🔺+0.3% 2.42 KB 2.42 KB 1.12 KB 1.12 KB NODE_PROD

Generated by 🚫 dangerJS

Copy link
Contributor

@necolas necolas left a comment

Choose a reason for hiding this comment

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

This feature is probably worth incorporating but not by changing when onFocus and onFocusChange get called. Instead, I think we should create a new callback – onFocusWithinChange

@devongovett
Copy link
Contributor Author

We'd also need an onFocusVisibleWithinChange then too, along with onFocusWithin and onBlurWithin?

@devongovett
Copy link
Contributor Author

My thought on that was you probably need either one or the other, not both. If your child is directly focusable, then you want focus events for that immediate child, otherwise you might want focus events for descendants. That's why I did it as a prop rather than a whole new set of events. But willing to change it if you think it's clearer.

@necolas
Copy link
Contributor

necolas commented Jun 11, 2019

I think this feature needs to be able to provide an equivalent to :focus-within styling without altering the existing semantics around "focus". Internally, FB5 originally defined the behaviour of onFocus to be closer to that of onFocusVisible (similar to how this patch is proposing a change to onFocus etc. behaviour). That caused problems and was replaced by what we built into Flare.

Here's an example of how I think it should work: https://codesandbox.io/s/experimental-event-api-n32y8. It doesn't rely on changing the propagation behaviour of the responder, changing the behaviour of existing callbacks, or introducing a within prop. If there are cases where the focus state of descendants needs to pierce the <Focus> boundaries, it's trivial to support that with a new prop like deep and making stopLocalPropagation false (without altering how the other callbacks work).

If you need more complex information about which elements are focused and whether they were focused with the keyboard, you should use multiple <Focus> elements. Eventually we'll have a general utility for querying the currently-active input modality. If there are scenarios where working with multiple <Focus> elements is common enough that the same work is being duplicated in many components, then we could look to fold it into Flare but behind different events rather than altering the existing ones.

@devongovett
Copy link
Contributor Author

What if you want to combine focus within and focus visible? i.e. in the date picker example I showed above, the focus ring gets bolder on focus visible. But the immediate child of the <Focus> is not focusable itself. It would be nice to combine the two states in this case such that a class could be applied when focus is visible within the component.

The current PR allows that by changing the behavior of the events to apply to descendants so that I can handle onFocusChange to add the initial focus ring, and onFocusVisibleChange to add the bolder focus ring. As far as I can tell this wouldn't be possible without adding an additional onFocusVisibleWithinChange event or the within option I added. The onFocusWithinChange and onFocusVisibleChange events cannot be combined, because onFocusVisibleChange would never fire on the parent since it is not directly focusable.

FWIW, I don't think this is directly possible in CSS either (combining :focus-visible and :focus-within).

@necolas
Copy link
Contributor

necolas commented Jun 11, 2019

What if you want to combine focus within and focus visible?

That's not what the patch is doing though; it's changing what the current API means entirely without providing focusWithin support. I think we should provide a means to add the focusWithin semantics (as I did for focusVisible), but onFocusVisibleChange should not be called when elements arbitrarily deep in the subtree are focused.

If you want to change the styles on your combobox based on whether one of its children is visibly focused you can track that state across multiple Focus components. If there are use cases where that becomes too complex, we could either add new callbacks for the different concepts or build a new event component for the purpose, e.g., FocusGroup or whatever. A different component would also not be constrained by the propagation settings of Focus, allowing it to listen to focus events coming from within Focus-wrapped subtrees. For example, we have FocusScope as distinct from Focus.

@devongovett
Copy link
Contributor Author

It's opt in, but sure, it does change the behavior when you opt in. What about passing both pieces of info to a single callback? i.e. onFocusChange could receive both whether or not the immediate child is focused, along with whether global focus visible state is set. Same thing for onFocusWithinChange.

@devongovett devongovett reopened this Jun 11, 2019
@devongovett
Copy link
Contributor Author

Sorry, hit the close button by accident. onFocusWithinChange is fine with me, I just have no way of knowing whether the global focus visible state is set.

@necolas
Copy link
Contributor

necolas commented Jun 11, 2019

At one point I kicked around the idea of making the "change" event handlers take a second argument for meta data. A bit like this:

const [ focused, updateFocus ] = useState(null)
const [ focusVisible, updateFocusVisible ] = useState(null)

<Focus
  onFocusChange={(bool, info) => {
    updateFocus(bool);
    updateFocusVisible(info.pointerType === 'keyboard');
  }}
>
  <button style={focusVisible ? styles.focusVisible : null} />
</Focus>

It was focus visibility that got me thinking about doing this. But the state of focus visibility can change independent of focus, i.e., mouse click after keyboard focus. That leaves the UI stuck in the focus visible state when it shouldn't be. (You can see this bug on mobile.twitter.com, e.g., the top nav links) There would be a similar limitation to an API like const onFocusWithinChange = (bool, { focusVisible }) => {}. That event doesn't fire every time focus moves within the subtree. So you would need onFocusVisibleWithinChange to correctly expose the state.

@devongovett
Copy link
Contributor Author

Yeah... maybe onFocusChange could pass an object argument with the state: onFocusChange({ isFocused: bool, isFocusWithin: bool, isFocusVisible: bool }). If any of those properties changed, the event would fire again.

@necolas
Copy link
Contributor

necolas commented Jun 11, 2019

It would still need isFocusVisibleWithin too. What you're suggesting is a variant on the API I described, but bundled into a single callback that is getting called a lot, rather than individual callbacks:

<Focus onFocusChange={(
  isFocused, isFocusVisible, isFocusWithin, isFocusVisibleWithin
) => {
  updateFocus(isFocused);
  updateFocusVisible(isFocusVisible);
  updateFocusWithin(isFocusWithin);
  updateFocusVisibleWithin(isFocusVisibleWithin)
}} />

// vs

<Focus
  onFocusChange={updateFocus}
  onFocusVisibleChange={updateFocusVisible}
  onFocusWithinChange={updateFocusWithin}
  onFocusVisibleWithinChange={updateFocusVisibleWithin}
/>

@devongovett
Copy link
Contributor Author

Focus visibility is global state, so I think isFocusVisible would be the same across all Focus components. So you could determine if focus was visible within with isFocusWithin && isFocusVisible, and if focus is visible on the direct child with isFocused && isFocusVisible.

The downside to this API as you said is that it might be called a lot more than needed for a particular usecase, e.g. if you don't use focus visibility or focus within state. You seem pretty convinced that separate callbacks is the way to go, so I'm happy to go that way as well. Just wanted to make sure the alternatives were explored.

@necolas
Copy link
Contributor

necolas commented Jun 11, 2019

Focus visibility is global state, so I think isFocusVisible would be the same across all Focus components.

Yes focus visibility is global but we're interested in whether a particular component should visibly display focus state. And that's what :focus-visible is about.

So you could determine if focus was visible within with isFocusWithin && isFocusVisible, and if focus is visible on the direct child with isFocused && isFocusVisible.

Not quite, because isFocusWithin is true when isFocused is true.

You seem pretty convinced that separate callbacks is the way to go

I think I've pointed out the problems with the alternative suggestions. The combobox can even be implemented with the existing <Focus> API using multiple components, but I think the concept of focus-within is worth adding and we can do that without changing everything else.

@devongovett devongovett force-pushed the focus-within branch 2 times, most recently from 3ae71cd to db96a1c Compare June 12, 2019 18:55
@devongovett
Copy link
Contributor Author

Ok I've updated the PR to implement onFocusWithinChange and onFocusVisibleWithinChange.

@devongovett devongovett changed the title [Flare] Add Focus within prop [Flare] Add Focus within support Jun 12, 2019
Copy link
Contributor

@necolas necolas left a comment

Choose a reason for hiding this comment

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

Thanks for updating the PR

@necolas
Copy link
Contributor

necolas commented Jun 13, 2019

I think this implementation has a bug with onFocusVisibleWithinChange, as it is getting called twice (false and true) when focus moves within the subtree. It shouldn't be called again until focus moves out of the subtree altogether or a non-keyboard pointer type is used. There's no test coverage in the PR for this scenario so I suspect you'll find tests fail when you add them. Demo (see console): https://codesandbox.io/s/experimental-event-api-03xu4

@necolas
Copy link
Contributor

necolas commented Jun 13, 2019

I don't think you need to track isFocusVisibleWithin separately on state either

@necolas
Copy link
Contributor

necolas commented Jun 18, 2019

@devongovett Are you planning to update this PR or should we pick it up from here?

@devongovett
Copy link
Contributor Author

Yeah sorry, just haven't had a chance yet. I'll try to get to it in the next couple days.

@devongovett
Copy link
Contributor Author

devongovett commented Jun 20, 2019

@necolas @trueadm Updated:

  • Left behavior of nested Focus event responders as is. Focus within with nested Focus components is now unsupported.
  • Fixed bug where onFocusVisibleWithinChange was called as focus moved within the subtree. Added a test for that.
  • Stopped tracking isFocusVisibleWithin separately in the state.

@necolas
Copy link
Contributor

necolas commented Jun 21, 2019

Latest patch seems to have regressed how focus is calculated https://codesandbox.io/s/experimental-event-api-03xu4

You can see how broken the focus interactions are now in this gif.

broken-focus-gif

Please keep the logic around all the existing events unchanged.

expect(onFocusChange).toHaveBeenCalledWith(false);
});

it('is not called after "blur" and "focus" events on nested descendants', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is incorrect behaviour

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why? It is verifying that onFocusChange is not called when focusing a descendant rather than the immediate child of the <Focus> component?

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh I missed that it wasn't starting with the root being focused. Given the UX is quite broken by the patch it's odd that no tests failed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I'll look into it and add more tests.

@526117

This comment has been minimized.

necolas added a commit to necolas/react that referenced this pull request Jul 17, 2019
FocusWithin is implemented as a separate responder, which keeps both focus
responders simple and allows for easier composition of behaviours.

Close facebook#15848
necolas added a commit to necolas/react that referenced this pull request Jul 17, 2019
FocusWithin is implemented as a separate responder, which keeps both focus
responders simple and allows for easier composition of behaviours.

Close facebook#15848
@necolas
Copy link
Contributor

necolas commented Jul 17, 2019

Closing this as I've implemented focus-within as a separate responder in #16152.

@necolas necolas closed this Jul 17, 2019
necolas added a commit to necolas/react that referenced this pull request Jul 17, 2019
FocusWithin is implemented as a separate responder, which keeps both focus
responders simple and allows for easier composition of behaviours.

Close facebook#15848
necolas added a commit to necolas/react that referenced this pull request Jul 17, 2019
FocusWithin is implemented as a separate responder, which keeps both focus
responders simple and allows for easier composition of behaviours.

Close facebook#15848
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants