1 | import * as React from "react";
|
2 | import { createHook } from "reakit-system/createHook";
|
3 | import { createComponent } from "reakit-system/createComponent";
|
4 | import { useForkRef } from "reakit-utils/useForkRef";
|
5 | import { hasFocusWithin } from "reakit-utils/hasFocusWithin";
|
6 | import { useLiveRef } from "reakit-utils/useLiveRef";
|
7 | import {
|
8 | PopoverDisclosureOptions,
|
9 | PopoverDisclosureHTMLProps,
|
10 | usePopoverDisclosure,
|
11 | } from "../Popover/PopoverDisclosure";
|
12 | import { MenuStateReturn } from "./MenuState";
|
13 | import { MenuContext } from "./__utils/MenuContext";
|
14 | import { findVisibleSubmenu } from "./__utils/findVisibleSubmenu";
|
15 | import { MENU_BUTTON_KEYS } from "./__keys";
|
16 |
|
17 | export type MenuButtonOptions = PopoverDisclosureOptions &
|
18 | Pick<
|
19 | Partial<MenuStateReturn>,
|
20 | | "hide"
|
21 | | "unstable_popoverStyles"
|
22 | | "unstable_arrowStyles"
|
23 | | "currentId"
|
24 | | "unstable_moves"
|
25 | | "move"
|
26 | > &
|
27 | Pick<MenuStateReturn, "show" | "placement" | "first" | "last">;
|
28 |
|
29 | export type MenuButtonHTMLProps = PopoverDisclosureHTMLProps;
|
30 |
|
31 | export type MenuButtonProps = MenuButtonOptions & MenuButtonHTMLProps;
|
32 |
|
33 | const noop = () => {};
|
34 |
|
35 | export const useMenuButton = createHook<MenuButtonOptions, MenuButtonHTMLProps>(
|
36 | {
|
37 | name: "MenuButton",
|
38 | compose: usePopoverDisclosure,
|
39 | keys: MENU_BUTTON_KEYS,
|
40 |
|
41 | propsAreEqual(prev, next) {
|
42 | const {
|
43 | unstable_popoverStyles: prevPopoverStyles,
|
44 | unstable_arrowStyles: prevArrowStyles,
|
45 | currentId: prevCurrentId,
|
46 | unstable_moves: prevMoves,
|
47 | ...prevProps
|
48 | } = prev;
|
49 | const {
|
50 | unstable_popoverStyles: nextPopoverStyles,
|
51 | unstable_arrowStyles: nextArrowStyles,
|
52 | currentId: nextCurrentId,
|
53 | unstable_moves: nextMoves,
|
54 | ...nextProps
|
55 | } = next;
|
56 | return usePopoverDisclosure.unstable_propsAreEqual(prevProps, nextProps);
|
57 | },
|
58 |
|
59 | useProps(
|
60 | options,
|
61 | {
|
62 | ref: htmlRef,
|
63 | onClick: htmlOnClick,
|
64 | onKeyDown: htmlOnKeyDown,
|
65 | onFocus: htmlOnFocus,
|
66 | onMouseEnter: htmlOnMouseEnter,
|
67 | onMouseDown: htmlOnMouseDown,
|
68 | ...htmlProps
|
69 | }
|
70 | ) {
|
71 | const parent = React.useContext(MenuContext);
|
72 | const ref = React.useRef<HTMLElement>(null);
|
73 | const hasPressedMouse = React.useRef(false);
|
74 | const [dir] = options.placement.split("-");
|
75 | const hasParent = !!parent;
|
76 | const parentIsMenuBar = parent?.role === "menubar";
|
77 | const disabled = options.disabled || htmlProps["aria-disabled"];
|
78 | const onClickRef = useLiveRef(htmlOnClick);
|
79 | const onKeyDownRef = useLiveRef(htmlOnKeyDown);
|
80 | const onFocusRef = useLiveRef(htmlOnFocus);
|
81 | const onMouseEnterRef = useLiveRef(htmlOnMouseEnter);
|
82 | const onMouseDownRef = useLiveRef(htmlOnMouseDown);
|
83 |
|
84 | const onKeyDown = React.useCallback(
|
85 | (event: React.KeyboardEvent<HTMLElement>) => {
|
86 | if (event.key === "Escape") {
|
87 |
|
88 |
|
89 | options.hide?.();
|
90 | } else if (!disabled) {
|
91 |
|
92 | const first = options.first && (() => setTimeout(options.first));
|
93 | const last = options.last && (() => setTimeout(options.last));
|
94 | const keyMap = {
|
95 | Enter: first,
|
96 | " ": first,
|
97 | ArrowUp: (dir === "top" || dir === "bottom") && last,
|
98 | ArrowRight: dir === "right" && first,
|
99 | ArrowDown: (dir === "bottom" || dir === "top") && first,
|
100 | ArrowLeft: dir === "left" && first,
|
101 | };
|
102 | const action = keyMap[event.key as keyof typeof keyMap];
|
103 | if (action) {
|
104 | event.preventDefault();
|
105 | event.stopPropagation();
|
106 | options.show?.();
|
107 | action();
|
108 | return;
|
109 | }
|
110 | }
|
111 | onKeyDownRef.current?.(event);
|
112 | },
|
113 | [disabled, options.hide, options.first, options.last, dir, options.show]
|
114 | );
|
115 |
|
116 | const onMouseEnter = React.useCallback(
|
117 | (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
118 | onMouseEnterRef.current?.(event);
|
119 | if (event.defaultPrevented) return;
|
120 |
|
121 |
|
122 | if (!parent) return;
|
123 | const element = event.currentTarget;
|
124 | if (parentIsMenuBar) {
|
125 |
|
126 |
|
127 | if (findVisibleSubmenu(parent.children)) {
|
128 | element.focus();
|
129 | }
|
130 | } else {
|
131 |
|
132 |
|
133 | setTimeout(() => {
|
134 | if (hasFocusWithin(element)) {
|
135 | options.show?.();
|
136 | }
|
137 | }, 200);
|
138 | }
|
139 | },
|
140 | [parent, parentIsMenuBar, options.show]
|
141 | );
|
142 |
|
143 | const onMouseDown = React.useCallback((event: React.MouseEvent) => {
|
144 |
|
145 |
|
146 |
|
147 |
|
148 |
|
149 |
|
150 |
|
151 | hasPressedMouse.current = true;
|
152 | onMouseDownRef.current?.(event);
|
153 | }, []);
|
154 |
|
155 | const onFocus = React.useCallback(
|
156 | (event: React.FocusEvent) => {
|
157 | onFocusRef.current?.(event);
|
158 | if (event.defaultPrevented) return;
|
159 | if (disabled) return;
|
160 | if (parentIsMenuBar && !hasPressedMouse.current) {
|
161 | options.show?.();
|
162 | }
|
163 | },
|
164 | [parentIsMenuBar, disabled, options.show]
|
165 | );
|
166 |
|
167 | // If disclosure is rendered as a menu bar item, it's toggable
|
168 | // That is, you can click on the expanded disclosure to close its menu.
|
169 | const onClick = React.useCallback(
|
170 | (event: React.MouseEvent) => {
|
171 | onClickRef.current?.(event);
|
172 | if (event.defaultPrevented) return;
|
173 |
|
174 |
|
175 | if (hasParent && !parentIsMenuBar) {
|
176 | options.show?.();
|
177 | } else {
|
178 |
|
179 |
|
180 | options.toggle?.();
|
181 |
|
182 | if (
|
183 | hasPressedMouse.current &&
|
184 | !parentIsMenuBar &&
|
185 | !options.visible
|
186 | ) {
|
187 | options.move?.(null);
|
188 | }
|
189 | }
|
190 | hasPressedMouse.current = false;
|
191 | },
|
192 | [
|
193 | hasParent,
|
194 | parentIsMenuBar,
|
195 | options.show,
|
196 | options.toggle,
|
197 | options.visible,
|
198 | options.move,
|
199 | ]
|
200 | );
|
201 |
|
202 | return {
|
203 | ref: useForkRef(ref, htmlRef),
|
204 | "aria-haspopup": "menu",
|
205 | onKeyDown,
|
206 | onMouseEnter,
|
207 | onMouseDown,
|
208 | onFocus,
|
209 | onClick,
|
210 | ...htmlProps,
|
211 | };
|
212 | },
|
213 |
|
214 | useComposeOptions(options) {
|
215 | return {
|
216 | ...options,
|
217 | // Toggling is handled by MenuButton
|
218 | toggle: noop,
|
219 | };
|
220 | },
|
221 | }
|
222 | );
|
223 |
|
224 | export const MenuButton = createComponent({
|
225 | as: "button",
|
226 | memo: true,
|
227 | useHook: useMenuButton,
|
228 | });
|
229 |
|
\ | No newline at end of file |