docs/react-v9/contributing/rfcs/react-components/components/moving-from-focus-zone-to-tabster.md
FocusZone to TabsterContributors: @khmakoto @ling1726
We currently use FocusZone for focus management in both v0 and v8. For our converged components we are trying out a different approach by using Tabster.
The reason for doing this is to use a framework-agnostic solution (as opposed to the React-centric FocusZone) that operates at the DOM level and has no external runtime dependencies.
However, we have to make sure that Tabster can actually cover all the scenarios FocusZone covered.
Ideally, FocusZone or FocusTrapZone like components should be avoided in most cases, and our components should provide first class support for correct keyboarding behavior. It is reasonable to assume that our components might not cover all our customers' scenarios. We should define the requirements that Fluent UI should support for focus management.
Type of keyboard and focus behavior that is the most commonly documented by WAI-ARIA and most commonly seen in
Although most widgets that involve collections will implement parts of the basic keyboarding with other special features i.e radio group.
The most important behavior in these scenarios is navigating with the use of Arrow keys
The most common example of grid keyboarding would be the infamous data grid.
Elements should be navigated with all arrow keys in a 2D grid, that does not need to be perfectly aligned.
This scenario can happen in the case of grids where a cell contains focusable items that can also be focused/navigated in the scope of the whole grid.
Another common scenario are interactive cards where the card itself is focusable/clickable but the contents in it can also contain focusable items.
Nested focusable items should also be able to use list/grid focus behaviors.
TODO #19324
Often clicking on a widget (e.g button) can toggle an option and cause it to disappear. Without any intervention the focus will be lost and applied directly to the document body, which is undesireable for screen reader users.
We should provide customers the ability to easily detect or avoid these situations by restoring focus to meaningful default elements.
There are a variety of factors to consider when trying to find focusable elements for features:
We should provide customers the ability to easily find focusable items that are focusable and also follow their custom filtering requirements.
FocusZoneFocusZone is a React component that is built to wrap around the elements whose focus it is to manage. It provides a number of props to abstract arrow key navigation behavior amongst tabbable elements and be able to group them into "zones" that you can transition between.
The React component pattern for focus management is not ideal, it breaks React component isolation. These components use DOM operations and a mix of global and synthetic event listeners to implement behavior. The components also expose a class-based interface for focus operations on children elements, which further breaks isolation.
Nested focusables cause issues on the current FocusZone component, where nested focusables behave inconsistently or incorrectly. Here are some recent issues related to this:
TabsterPhilosophy behind Tabster:
Tabindex on Steroids
Tabster is designed to be a lower level utility that manages the state of focusable elements independent from a rendering framework.
Internally, Tabster leverages the TreeWalker API, which is the recommended DOM tree traversal method according to the w3c.
Tabster manages focus on a lower level than the rendering framework and acts on the visible DOM. Since operations are run on visible DOM there is a much clearer path for handling dynamically loaded content that would otherwise require effects outside the rendering lifecycle in React components, which breaks component isolation principles.
Tabster has 2 levels of API:
data-tabster DOM attributeNesting focusables is a problem that Tabster explicitly tries to solve. It does this by bringing focus management to a lower level than the rendering framework, trying to accomplish this consistently without worrying about rendering cycles.
From the highest level, Tabster is opt-in and will not work unless a specific root element has been configured.
The description of the each constituent module in the library can be found in the Tabster README.
The bundle size of each of the different modules of the library are listed in the below table (as of May 5, 2021). The library is fully tree-shakeable, and for most cases only the core part of the library is expected to be used.
| Module | minified (kb) | gzipped(kb |
|---|---|---|
| Core | 45.3 | 12.2 |
| Deloser | 13.2 | 3.2 |
| Modalizer | 4.5 | 0.8 |
| ObservedElement | 3.1 | 0.7 |
| Outline | 7.9 | 2 |
| CrossOrigin | 18.9 | 4.4 |
#18700 refactors FocusZone and FocusTrapZone to be independent of old v8 styling utilities and measures the bundle size. We should eventually reach the same targets with tabster
Tabster's Core API provides the following functionalities:
All of those functionalities are opt-in.
Elements with focus on them disappearing from the application and causing the focus to suddenly be on the body is a common problem in the web.
The Deloser API tries to solve this by tracking the focus history and automatically restoring focus when it is lost.
The API is opt-in, and can be declarative (data-tabster) for the simplest use case. It can also be paused/resumed during runtime.
Modal dialogs generally have these hard requirements:
This API is also opt-in and handles all the above requirements.
FocusZone and TabsterBelow, we present a comparison between the functionality available in FocusZone and Tabster, focusing on what is covered by either or both of them and seen from the lens of someone who wants to transition from using FocusZone to using Tabster in their project. The way we are going to approach this is by taking a look at which FocusZone props/capabilities are covered in Tabster (either fully or partially) and which ones are missing, all while skipping over deprecated props.
FocusZone that is fully covered by Tabsterfocus(forceIntoFirstElement?: boolean): boolean
FocusZone, returning true if focus could be set to an active element and false otherwise. If forceIntoFirstElement is true, focus will be forced into the first element, even if the focus is already within the FocusZone.Tabster: Use findFirstFocusable from useFocusFinders in @fluentui/react-tabster instead.focusLast(): boolean
FocusZone, returning true if focus could be set to an active element and false otherwise.Tabster: Use findLastFocusable from useFocusFinders in @fluentui/react-tabster instead.focusElement(childElement?: HTMLElement, forceAlignment?: boolean): boolean
childElement) within the zone. This can be used in conjunction with shouldReceiveFocus to create delayed focus scenarios (like animate the scroll position to the correct location and then focus). If forceAlignment is true, then focus alignment will be set according to the element provided. Returns true if focus could be set to an active element and false otherwise.Tabster: Use findAllFocusable from useFocusFinders in @fluentui/react-tabster instead. This function will walk the HTML tree and return nodes that match a callback similar to find in native JS.isCircularNavigation?: boolean
FocusZone will cycle to the beginning of the targets once the user navigates to the next target while at the end, and to the end when navigating to the previous target while at the beginning.Tabster: useArrowNavigationGroup from @fluentui/react-tabster can be used to allow for cyclic navigation, specified by passing the circular prop as part of the hook's options argument.preventFocusRestoration?: boolean
FocusZone from attempting to restore the focus to the inner element when the focus is on the root element after componentDidUpdate.Tabster: If "deloser" part of Tabster is not declared then it does not keep track of focus history for focus restoration, which essentially accomplishes the same goal.shouldFocusOnMount?: boolean
FocusZone mount.Tabster: The same functionality can be accomplished by using one of the focus finding utilities from useFocusFinders (part of @fluentui/react-tabster) either in an effect for function components or in the componentDidMount lifecycle method for class-based components.allowFocusRoot?: boolean
FocusZone at render time.Tabster: This is equivalent to adding tabIndex={0} to the root element supporting Tabster.onFocus?: (event: React.FocusEvent<HTMLElement>) => void
FocusZone.Tabster: Native onFocus callback can be used instead.FocusZone that is partially covered by Tabsterdirection?: FocusZoneDirection
FocusZoneDirection is an enum with the following values:
vertical: Reacts to Up/Down arrows.horizontal: Reacts to Left/Right arrows.bidirectional: Reacts to all arrows.domOrder: Reacts to all arrows by navigating to the next item in DOM by pressing Right/Down arrow keys and to the previous item in the DOM by pressing Left/Up arrow keys. Left and Right arrow keys are swapped in RTL mode.Tabster: useArrowNavigationGroup from @fluentui/react-tabster can be used to allow for arrow navigation for either a vertical or horizontal axis, specified by passing the axis prop as part of the hook's options argument.Tabster?: bidirectional and domOrder navigation are not currently available via useArrowNavigationGroup.handleTabKey?: FocusZoneTabbableElements
FocusZone. An unfortunate side effect is that users will not be able to tab out of the FocusZone and have to hit some other key.FocusZoneTabbableElements is an enum with the following values:
none: Tabbing is not allowed.all: All tabbing is allowed.inputOnly: Tabbing is allowed only on input elements.Tabster: Tabster allows the user to specify the navigation type of the Mover to be handled via arrow keys, the tab key, or both.Tabster?: And abstraction similar to useArrowNavigationGroup would be nice to have. Even then, there is no equivalent currently for inputOnly tabbing.shouldEnterInnerZone?: (ev: React.KeyboardEvent<HTMLElement>) => boolean
Tabster: The "groupper" part of Tabster groups focusables and can handle nesting.Tabster?: We need to be very clear about how we support nested focusables to determine what kind of API is needed here.shouldFocusInnerElementWhenReceivedFocus?: boolean
FocusZone's root element (container) receives focus, the focus will land either on the defaultTabbableElement (if set) or on the first tabbable element of this FocusZone. Commonly used in the case of nested FocusZones where the nested FocusZone's container is a focusable element.Tabster: The "groupper" part of Tabster groups focusables and can handle nesting.Tabster?: We need to be very clear about how we support nested focusables to determine what kind of API is needed here.FocusZone that is not covered by TabstercomponentRef?: IRefObject<IFocusZone>
IFocusZone interface used instead of ref for accessing the public methods and properties of the component.Tabster is not a component but a set of utilities, so it has no need for a ref.elementRef?: React.Ref<HTMLElement>
Tabster is not a component but a set of utilities, so it has no need for a ref.className?: string
Tabster is not a component but a set of utilities, so it has no element to apply a class to.disabled?: boolean
FocusZone component will not be tabbable and keyboard navigation will be disabled. This does not affect the disabled attribute of any child.Tabster is not a component but a set of utilities, so it has no element to disable.as?: React.ElementType
FocusZone component.as.preventDefaultWhenHandled?: boolean
FocusZone prevents the default behavior of keyboard events when changing focus between elements.shouldReceiveFocus?: (childElement?: HTMLElement) => boolean
setFocusAlignment(point: Point): void
point) as the reference, rather than a center based on the last horizontal motion.defaultTabbableElement?: string | ((root: HTMLElement) => HTMLElement)
FocusZone. If a string is passed then it is treated as a selector for identifying the inital tabbable element. If a function is passed then it uses the root element as a parameter to return the initial tabbable element.@fluentui/react-tabster. If not, we should skip this prop and regard it as "not needed". Anyways, it is too early right now to make a call on it and the actual API would probably have to look very different to what it looks like in FocusZone today.shouldResetActiveElementWhenTabFromZone?: boolean
Tab key is not handled by FocusZone, resets current active element to null value. For example, when roving index is not desirable and focus should always reset to the default tabbable element.defaultTabbableElement then we might want something like this going forward as well, but this is pending the decision on the defaultTabbableElement prop.onActiveElementChanged?: (element?: HTMLElement, ev?: React.FocusEvent<HTMLElement>) => void
Tabster, although it might be just the native onFocus callback.pagingSupportDisabled?: boolean
Page Up and Page Down keyboard scenarios.Tabster needs to support Page Up and Page Down scenarios, but that is still an unknown as of right now.checkForNoWrap?: boolean
FocusZone that need more info to know if they have an equivalent in TabstershouldRaiseClicks?: boolean
FocusZone will walk up the DOM trying to invoke click callbacks on focusable elements on Enter and Space keydowns to ensure accessibility for tags that do not guarantee this behavior.shouldInputLoseFocusOnArrowKey?: (inputElement: HTMLInputElement) => boolean
stopFocusPropagation?: boolean
FocusZone should allow focus events to propagate past the FocusZone.