UNPKG

7.73 kBPlain TextView Raw
1import * as React from "react";
2import { createHook } from "reakit-system/createHook";
3import { createComponent } from "reakit-system/createComponent";
4import { useForkRef } from "reakit-utils/useForkRef";
5import { hasFocusWithin } from "reakit-utils/hasFocusWithin";
6import { useLiveRef } from "reakit-utils/useLiveRef";
7import {
8 PopoverDisclosureOptions,
9 PopoverDisclosureHTMLProps,
10 usePopoverDisclosure,
11} from "../Popover/PopoverDisclosure";
12import { MenuStateReturn } from "./MenuState";
13import { MenuContext } from "./__utils/MenuContext";
14import { findVisibleSubmenu } from "./__utils/findVisibleSubmenu";
15import { MENU_BUTTON_KEYS } from "./__keys";
16
17export 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
29export type MenuButtonHTMLProps = PopoverDisclosureHTMLProps;
30
31export type MenuButtonProps = MenuButtonOptions & MenuButtonHTMLProps;
32
33const noop = () => {};
34
35export 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 // Doesn't prevent default on Escape, otherwise we can't close
88 // dialogs when MenuButton is focused
89 options.hide?.();
90 } else if (!disabled) {
91 // setTimeout prevents scroll jump
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 // MenuButton's don't do anything on mouse over when they aren't
121 // cointained within a Menu/MenuBar
122 if (!parent) return;
123 const element = event.currentTarget;
124 if (parentIsMenuBar) {
125 // if MenuButton is an item inside a MenuBar, it'll only open
126 // if there's already another sibling expanded MenuButton
127 if (findVisibleSubmenu(parent.children)) {
128 element.focus();
129 }
130 } else {
131 // If it's in a Menu, open after a short delay
132 // TODO: Make the delay a prop?
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 // When in menu bar, the menu button can be activated either by focus
145 // or click, but we don't want both to trigger sequentially.
146 // Otherwise, onClick would toggle (hide) the menu right after it got
147 // shown on focus.
148 // This is also useful so we know if the menu button has been clicked
149 // using mouse or keyboard. On mouse click, we don't automatically
150 // focus the first menu item.
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 // If menu button is a menu item inside a menu (not menu bar), you
174 // can't close it by clicking on it again.
175 if (hasParent && !parentIsMenuBar) {
176 options.show?.();
177 } else {
178 // Otherwise, if menu button is a menu bar item or an orphan menu
179 // button, it's toggable.
180 options.toggle?.();
181 // Focus the menu popover when it's opened with mouse click.
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
224export const MenuButton = createComponent({
225 as: "button",
226 memo: true,
227 useHook: useMenuButton,
228});
229
\No newline at end of file