1 | import * as React from "react";
|
2 | import { createComponent } from "reakit-system/createComponent";
|
3 | import { createHook } from "reakit-system/createHook";
|
4 | import { useForkRef } from "reakit-utils/useForkRef";
|
5 | import { useIsomorphicEffect } from "reakit-utils/useIsomorphicEffect";
|
6 | import { useLiveRef } from "reakit-utils/useLiveRef";
|
7 | import { warning } from "reakit-warning";
|
8 | import { hasFocusWithin } from "reakit-utils/hasFocusWithin";
|
9 | import { isButton } from "reakit-utils/isButton";
|
10 | import { isPortalEvent } from "reakit-utils/isPortalEvent";
|
11 | import { isUA } from "reakit-utils/dom";
|
12 | import { isFocusable } from "reakit-utils/tabbable";
|
13 | import { RoleOptions, RoleHTMLProps, useRole } from "../Role/Role";
|
14 | import { TABBABLE_KEYS } from "./__keys";
|
15 |
|
16 | export type TabbableOptions = RoleOptions & {
|
17 | |
18 |
|
19 |
|
20 | disabled?: boolean;
|
21 | |
22 |
|
23 |
|
24 |
|
25 |
|
26 | focusable?: boolean;
|
27 | };
|
28 |
|
29 | export type TabbableHTMLProps = RoleHTMLProps & {
|
30 | disabled?: boolean;
|
31 | };
|
32 |
|
33 | export type TabbableProps = TabbableOptions & TabbableHTMLProps;
|
34 |
|
35 | const isSafariOrFirefoxOnMac =
|
36 | isUA("Mac") && !isUA("Chrome") && (isUA("Safari") || isUA("Firefox"));
|
37 |
|
38 | function focusIfNeeded(element: HTMLElement) {
|
39 | if (!hasFocusWithin(element) && isFocusable(element)) {
|
40 | element.focus();
|
41 | }
|
42 | }
|
43 |
|
44 | function isNativeTabbable(element: Element) {
|
45 | return ["BUTTON", "INPUT", "SELECT", "TEXTAREA", "A"].includes(
|
46 | element.tagName
|
47 | );
|
48 | }
|
49 |
|
50 | function supportsDisabledAttribute(element: Element) {
|
51 | return ["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(element.tagName);
|
52 | }
|
53 |
|
54 | function getTabIndex(
|
55 | trulyDisabled: boolean,
|
56 | nativeTabbable: boolean,
|
57 | supportsDisabled: boolean,
|
58 | htmlTabIndex?: number
|
59 | ) {
|
60 | if (trulyDisabled) {
|
61 | if (nativeTabbable && !supportsDisabled) {
|
62 |
|
63 |
|
64 | return -1;
|
65 | }
|
66 |
|
67 | return undefined;
|
68 | }
|
69 | if (nativeTabbable) {
|
70 |
|
71 |
|
72 | return htmlTabIndex;
|
73 | }
|
74 |
|
75 |
|
76 | return htmlTabIndex || 0;
|
77 | }
|
78 |
|
79 | function 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 |
|
98 | export 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 |
|
168 |
|
169 |
|
170 |
|
171 |
|
172 | if (!isSafariOrFirefoxOnMac) return;
|
173 | if (isPortalEvent(event)) return;
|
174 | if (!isButton(element)) return;
|
175 |
|
176 |
|
177 |
|
178 | const raf = requestAnimationFrame(() => {
|
179 | element.removeEventListener("mouseup", focusImmediately, true);
|
180 | focusIfNeeded(element);
|
181 | });
|
182 |
|
183 |
|
184 |
|
185 | const focusImmediately = () => {
|
186 | cancelAnimationFrame(raf);
|
187 | focusIfNeeded(element);
|
188 | };
|
189 |
|
190 |
|
191 |
|
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 |
|
220 | export const Tabbable = createComponent({
|
221 | as: "div",
|
222 | useHook: useTabbable,
|
223 | });
|