UNPKG

7.3 kBPlain TextView Raw
1import * as React from "react";
2import { createComponent } from "reakit-system/createComponent";
3import { createHook } from "reakit-system/createHook";
4import { useForkRef } from "reakit-utils/useForkRef";
5import { useIsomorphicEffect } from "reakit-utils/useIsomorphicEffect";
6import { useLiveRef } from "reakit-utils/useLiveRef";
7import { warning } from "reakit-warning";
8import { hasFocusWithin } from "reakit-utils/hasFocusWithin";
9import { isButton } from "reakit-utils/isButton";
10import { isPortalEvent } from "reakit-utils/isPortalEvent";
11import { isUA } from "reakit-utils/dom";
12import { isFocusable } from "reakit-utils/tabbable";
13import { RoleOptions, RoleHTMLProps, useRole } from "../Role/Role";
14import { TABBABLE_KEYS } from "./__keys";
15
16export type TabbableOptions = RoleOptions & {
17 /**
18 * Same as the HTML attribute.
19 */
20 disabled?: boolean;
21 /**
22 * When an element is `disabled`, it may still be `focusable`. It works
23 * similarly to `readOnly` on form elements. In this case, only
24 * `aria-disabled` will be set.
25 */
26 focusable?: boolean;
27};
28
29export type TabbableHTMLProps = RoleHTMLProps & {
30 disabled?: boolean;
31};
32
33export type TabbableProps = TabbableOptions & TabbableHTMLProps;
34
35const isSafariOrFirefoxOnMac =
36 isUA("Mac") && !isUA("Chrome") && (isUA("Safari") || isUA("Firefox"));
37
38function focusIfNeeded(element: HTMLElement) {
39 if (!hasFocusWithin(element) && isFocusable(element)) {
40 element.focus();
41 }
42}
43
44function isNativeTabbable(element: Element) {
45 return ["BUTTON", "INPUT", "SELECT", "TEXTAREA", "A"].includes(
46 element.tagName
47 );
48}
49
50function supportsDisabledAttribute(element: Element) {
51 return ["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(element.tagName);
52}
53
54function getTabIndex(
55 trulyDisabled: boolean,
56 nativeTabbable: boolean,
57 supportsDisabled: boolean,
58 htmlTabIndex?: number
59) {
60 if (trulyDisabled) {
61 if (nativeTabbable && !supportsDisabled) {
62 // Anchor, audio and video tags don't support the `disabled` attribute.
63 // We must pass tabIndex={-1} so they don't receive focus on tab.
64 return -1;
65 }
66 // Elements that support the `disabled` attribute don't need tabIndex.
67 return undefined;
68 }
69 if (nativeTabbable) {
70 // If the element is enabled and it's natively tabbable, we don't need to
71 // specify a tabIndex attribute unless it's explicitly set by the user.
72 return htmlTabIndex;
73 }
74 // If the element is enabled and is not natively tabbable, we have to
75 // fallback tabIndex={0}.
76 return htmlTabIndex || 0;
77}
78
79function useDisableEvent(
80 htmlEventRef: React.RefObject<
81 React.EventHandler<React.SyntheticEvent> | undefined
82 >,
83 disabled?: boolean
84) {
85 return React.useCallback(
86 (event: React.SyntheticEvent) => {
87 htmlEventRef.current?.(event);
88 if (event.defaultPrevented) return;
89 if (disabled) {
90 event.stopPropagation();
91 event.preventDefault();
92 }
93 },
94 [htmlEventRef, disabled]
95 );
96}
97
98export const useTabbable = createHook<TabbableOptions, TabbableHTMLProps>({
99 name: "Tabbable",
100 compose: useRole,
101 keys: TABBABLE_KEYS,
102
103 useOptions(options, { disabled }) {
104 return { disabled, ...options };
105 },
106
107 useProps(
108 options,
109 {
110 ref: htmlRef,
111 tabIndex: htmlTabIndex,
112 onClickCapture: htmlOnClickCapture,
113 onMouseDownCapture: htmlOnMouseDownCapture,
114 onMouseDown: htmlOnMouseDown,
115 onKeyPressCapture: htmlOnKeyPressCapture,
116 style: htmlStyle,
117 ...htmlProps
118 }
119 ) {
120 const ref = React.useRef<HTMLElement>(null);
121 const onClickCaptureRef = useLiveRef(htmlOnClickCapture);
122 const onMouseDownCaptureRef = useLiveRef(htmlOnMouseDownCapture);
123 const onMouseDownRef = useLiveRef(htmlOnMouseDown);
124 const onKeyPressCaptureRef = useLiveRef(htmlOnKeyPressCapture);
125 const trulyDisabled = !!options.disabled && !options.focusable;
126 const [nativeTabbable, setNativeTabbable] = React.useState(true);
127 const [supportsDisabled, setSupportsDisabled] = React.useState(true);
128 const style = options.disabled
129 ? { pointerEvents: "none" as const, ...htmlStyle }
130 : htmlStyle;
131
132 useIsomorphicEffect(() => {
133 const tabbable = ref.current;
134 if (!tabbable) {
135 warning(
136 true,
137 "Can't determine if the element is a native tabbable element because `ref` wasn't passed to the component.",
138 "See https://reakit.io/docs/tabbable"
139 );
140 return;
141 }
142 if (!isNativeTabbable(tabbable)) {
143 setNativeTabbable(false);
144 }
145 if (!supportsDisabledAttribute(tabbable)) {
146 setSupportsDisabled(false);
147 }
148 }, []);
149
150 const onClickCapture = useDisableEvent(onClickCaptureRef, options.disabled);
151
152 const onMouseDownCapture = useDisableEvent(
153 onMouseDownCaptureRef,
154 options.disabled
155 );
156
157 const onKeyPressCapture = useDisableEvent(
158 onKeyPressCaptureRef,
159 options.disabled
160 );
161
162 const onMouseDown = React.useCallback(
163 (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
164 onMouseDownRef.current?.(event);
165 const element = event.currentTarget;
166 if (event.defaultPrevented) return;
167 // Safari and Firefox on MacOS don't focus on buttons on mouse down
168 // like other browsers/platforms. Instead, they focus on the closest
169 // focusable ancestor element, which is ultimately the body element. So
170 // we make sure to give focus to the tabbable element on mouse down so
171 // it works consistently across browsers.
172 if (!isSafariOrFirefoxOnMac) return;
173 if (isPortalEvent(event)) return;
174 if (!isButton(element)) return;
175 // We can't focus right away after on mouse down, otherwise it would
176 // prevent drag events from happening. So we schedule the focus to the
177 // next animation frame.
178 const raf = requestAnimationFrame(() => {
179 element.removeEventListener("mouseup", focusImmediately, true);
180 focusIfNeeded(element);
181 });
182 // If mouseUp happens before the next animation frame (which is common
183 // on touch screens or by just tapping the trackpad on MacBook's), we
184 // cancel the animation frame and immediately focus on the element.
185 const focusImmediately = () => {
186 cancelAnimationFrame(raf);
187 focusIfNeeded(element);
188 };
189 // By listening to the event in the capture phase, we make sure the
190 // focus event is fired before the onMouseUp and onMouseUpCapture React
191 // events, which is aligned with the default browser behavior.
192 element.addEventListener("mouseup", focusImmediately, {
193 once: true,
194 capture: true,
195 });
196 },
197 []
198 );
199
200 return {
201 ref: useForkRef(ref, htmlRef),
202 style,
203 tabIndex: getTabIndex(
204 trulyDisabled,
205 nativeTabbable,
206 supportsDisabled,
207 htmlTabIndex
208 ),
209 disabled: trulyDisabled && supportsDisabled ? true : undefined,
210 "aria-disabled": options.disabled ? true : undefined,
211 onClickCapture,
212 onMouseDownCapture,
213 onMouseDown,
214 onKeyPressCapture,
215 ...htmlProps,
216 };
217 },
218});
219
220export const Tabbable = createComponent({
221 as: "div",
222 useHook: useTabbable,
223});