Skip to content

Commit 3ad9c26

Browse files
authored
fix: v3 menus alternative approach (#9345)
* fix: v3 menus alternative approach * restrict to only submenu and remove extra export * remove extra export
1 parent f208529 commit 3ad9c26

File tree

7 files changed

+1438
-1
lines changed

7 files changed

+1438
-1
lines changed
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {AriaPopoverProps, DismissButton, PopoverAria} from '@react-aria/overlays';
14+
import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils';
15+
import {DOMRef, RefObject, StyleProps} from '@react-types/shared';
16+
import {FocusWithinProps, useFocusWithin} from '@react-aria/interactions';
17+
import {mergeProps, useLayoutEffect, useObjectRef} from '@react-aria/utils';
18+
import {Overlay} from '@react-spectrum/overlays';
19+
import {OverlayTriggerState} from '@react-stately/overlays';
20+
import overrideStyles from './overlays.css';
21+
import React, {ForwardedRef, forwardRef, ReactNode, useRef, useState} from 'react';
22+
import styles from '@adobe/spectrum-css-temp/components/popover/vars.css';
23+
import {Underlay} from './Underlay';
24+
import {usePopover} from './usePopover';
25+
26+
interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef' | 'maxHeight'>, FocusWithinProps, StyleProps {
27+
children: ReactNode,
28+
hideArrow?: boolean,
29+
state: OverlayTriggerState,
30+
shouldContainFocus?: boolean,
31+
onEntering?: () => void,
32+
onEnter?: () => void,
33+
onEntered?: () => void,
34+
onExiting?: () => void,
35+
onExited?: () => void,
36+
onExit?: () => void,
37+
container?: HTMLElement,
38+
disableFocusManagement?: boolean,
39+
enableBothDismissButtons?: boolean,
40+
onDismissButtonPress?: () => void
41+
}
42+
43+
interface PopoverWrapperProps extends PopoverProps, FocusWithinProps {
44+
isOpen?: boolean,
45+
wrapperRef: RefObject<HTMLDivElement | null>
46+
}
47+
48+
interface ArrowProps {
49+
arrowProps: PopoverAria['arrowProps'],
50+
isLandscape: boolean,
51+
arrowRef?: RefObject<SVGSVGElement | null>,
52+
primary: number,
53+
secondary: number,
54+
borderDiagonal: number
55+
}
56+
57+
/**
58+
* Arrow placement can be done pointing right or down because those paths start at 0, x or y. Because the
59+
* other two don't, they start at a fractional pixel value, it introduces rounding differences between browsers and
60+
* between display types (retina with subpixels vs not retina). By flipping them with CSS we can ensure that
61+
* the path always starts at 0 so that it perfectly overlaps the popover's border.
62+
* See bottom of file for more explanation.
63+
*/
64+
let arrowPlacement = {
65+
left: 'right',
66+
right: 'right',
67+
top: 'bottom',
68+
bottom: 'bottom'
69+
};
70+
71+
export const Popover = forwardRef(function Popover(props: PopoverProps, ref: DOMRef<HTMLDivElement>) {
72+
let {
73+
children,
74+
state,
75+
...otherProps
76+
} = props;
77+
let domRef = useDOMRef(ref);
78+
let wrapperRef = useRef<HTMLDivElement>(null);
79+
80+
return (
81+
<Overlay {...otherProps} isOpen={state.isOpen} nodeRef={wrapperRef}>
82+
<PopoverWrapper ref={domRef} {...props} wrapperRef={wrapperRef}>
83+
{children}
84+
</PopoverWrapper>
85+
</Overlay>
86+
);
87+
});
88+
89+
const PopoverWrapper = forwardRef((props: PopoverWrapperProps, ref: ForwardedRef<HTMLDivElement | null>) => {
90+
let {
91+
children,
92+
isOpen,
93+
hideArrow,
94+
isNonModal,
95+
enableBothDismissButtons,
96+
state,
97+
wrapperRef,
98+
onDismissButtonPress = () => state.close()
99+
} = props;
100+
let {styleProps} = useStyleProps(props);
101+
let objRef = useObjectRef(ref);
102+
103+
let {size, borderWidth, arrowRef} = useArrowSize();
104+
const borderRadius = usePopoverBorderRadius(objRef);
105+
let borderDiagonal = borderWidth * Math.SQRT2;
106+
let primary = size + borderDiagonal;
107+
let secondary = primary * 2;
108+
let {
109+
popoverProps,
110+
arrowProps,
111+
underlayProps,
112+
placement
113+
} = usePopover({
114+
...props,
115+
popoverRef: objRef,
116+
maxHeight: undefined,
117+
arrowSize: hideArrow ? 0 : secondary,
118+
arrowBoundaryOffset: borderRadius
119+
}, state);
120+
let {focusWithinProps} = useFocusWithin(props);
121+
122+
// Attach Transition's nodeRef to outermost wrapper for node.reflow: https://github.com/reactjs/react-transition-group/blob/c89f807067b32eea6f68fd6c622190d88ced82e2/src/Transition.js#L231
123+
return (
124+
<div ref={wrapperRef}>
125+
{!isNonModal && <Underlay isTransparent {...mergeProps(underlayProps)} isOpen={isOpen} /> }
126+
<div
127+
{...styleProps}
128+
{...mergeProps(popoverProps, focusWithinProps)}
129+
style={{
130+
...styleProps.style,
131+
...popoverProps.style
132+
}}
133+
ref={objRef}
134+
className={
135+
classNames(
136+
styles,
137+
'spectrum-Popover',
138+
`spectrum-Popover--${placement}`,
139+
{
140+
'spectrum-Popover--withTip': !hideArrow,
141+
'is-open': isOpen,
142+
[`is-open--${placement}`]: isOpen
143+
},
144+
classNames(
145+
overrideStyles,
146+
'spectrum-Popover',
147+
'react-spectrum-Popover'
148+
),
149+
styleProps.className
150+
)
151+
}
152+
role="presentation"
153+
data-testid="popover">
154+
{(!isNonModal || enableBothDismissButtons) && <DismissButton onDismiss={onDismissButtonPress} />}
155+
{children}
156+
{hideArrow ? null : (
157+
<Arrow
158+
arrowProps={arrowProps}
159+
isLandscape={placement != null ? arrowPlacement[placement] === 'bottom' : false}
160+
arrowRef={arrowRef}
161+
primary={primary}
162+
secondary={secondary}
163+
borderDiagonal={borderDiagonal} />
164+
)}
165+
<DismissButton onDismiss={onDismissButtonPress} />
166+
</div>
167+
</div>
168+
);
169+
});
170+
171+
function usePopoverBorderRadius(popoverRef: RefObject<HTMLDivElement | null>) {
172+
let [borderRadius, setBorderRadius] = useState(0);
173+
useLayoutEffect(() => {
174+
if (popoverRef.current) {
175+
let spectrumBorderRadius = window.getComputedStyle(popoverRef.current).borderRadius;
176+
if (spectrumBorderRadius !== '') {
177+
setBorderRadius(parseInt(spectrumBorderRadius, 10));
178+
}
179+
}
180+
}, [popoverRef]);
181+
return borderRadius;
182+
}
183+
184+
function useArrowSize() {
185+
let [size, setSize] = useState(20);
186+
let [borderWidth, setBorderWidth] = useState(1);
187+
let arrowRef = useRef<SVGSVGElement>(null);
188+
// get the css value for the tip size and divide it by 2 for this arrow implementation
189+
useLayoutEffect(() => {
190+
if (arrowRef.current) {
191+
let spectrumTipWidth = window.getComputedStyle(arrowRef.current)
192+
.getPropertyValue('--spectrum-popover-tip-size');
193+
if (spectrumTipWidth !== '') {
194+
setSize(parseInt(spectrumTipWidth, 10) / 2);
195+
}
196+
197+
let spectrumBorderWidth = window.getComputedStyle(arrowRef.current)
198+
.getPropertyValue('--spectrum-popover-tip-borderWidth');
199+
if (spectrumBorderWidth !== '') {
200+
setBorderWidth(parseInt(spectrumBorderWidth, 10));
201+
}
202+
}
203+
}, []);
204+
return {size, borderWidth, arrowRef};
205+
}
206+
207+
function Arrow(props: ArrowProps) {
208+
let {primary, secondary, isLandscape, arrowProps, borderDiagonal, arrowRef} = props;
209+
let halfBorderDiagonal = borderDiagonal / 2;
210+
211+
let primaryStart = 0;
212+
let primaryEnd = primary - halfBorderDiagonal;
213+
214+
let secondaryStart = halfBorderDiagonal;
215+
let secondaryMiddle = secondary / 2;
216+
let secondaryEnd = secondary - halfBorderDiagonal;
217+
218+
let pathData = isLandscape ? [
219+
'M', secondaryStart, primaryStart,
220+
'L', secondaryMiddle, primaryEnd,
221+
'L', secondaryEnd, primaryStart
222+
] : [
223+
'M', primaryStart, secondaryStart,
224+
'L', primaryEnd, secondaryMiddle,
225+
'L', primaryStart, secondaryEnd
226+
];
227+
228+
/* use ceil because the svg needs to always accommodate the path inside it */
229+
return (
230+
<svg
231+
xmlns="http://www.w3.org/svg/2000"
232+
width={Math.ceil(isLandscape ? secondary : primary)}
233+
height={Math.ceil(isLandscape ? primary : secondary)}
234+
className={classNames(styles, 'spectrum-Popover-tip')}
235+
ref={arrowRef}
236+
{...arrowProps}>
237+
<path className={classNames(styles, 'spectrum-Popover-tip-triangle')} d={pathData.join(' ')} />
238+
</svg>
239+
);
240+
}

packages/@react-spectrum/menu/src/SubmenuTrigger.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {classNames, useIsMobileDevice} from '@react-spectrum/utils';
1414
import {Key} from '@react-types/shared';
1515
import {MenuContext, SubmenuTriggerContext, useMenuStateContext} from './context';
1616
import {mergeProps} from '@react-aria/utils';
17-
import {Popover} from '@react-spectrum/overlays';
17+
import {Popover} from './Popover';
1818
import React, {type JSX, ReactElement, useRef} from 'react';
1919
import ReactDOM from 'react-dom';
2020
import styles from '@adobe/spectrum-css-temp/components/menu/vars.css';
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {classNames} from '@react-spectrum/utils';
14+
import {isScrollable} from '@react-aria/utils';
15+
import React, {JSX} from 'react';
16+
import underlayStyles from '@adobe/spectrum-css-temp/components/underlay/vars.css';
17+
18+
interface UnderlayProps {
19+
isOpen?: boolean,
20+
isTransparent?: boolean
21+
}
22+
23+
export function Underlay({isOpen, isTransparent, ...otherProps}: UnderlayProps): JSX.Element {
24+
let pageHeight: number | undefined = undefined;
25+
if (typeof document !== 'undefined') {
26+
let scrollingElement = isScrollable(document.body) ? document.body : document.scrollingElement || document.documentElement;
27+
// Prevent Firefox from adding scrollbars when the page has a fractional height.
28+
let fractionalHeightDifference = scrollingElement.getBoundingClientRect().height % 1;
29+
pageHeight = scrollingElement.scrollHeight - fractionalHeightDifference;
30+
}
31+
32+
return (
33+
<div
34+
data-testid="underlay"
35+
{...otherProps}
36+
// Cover the entire document so iOS 26 Safari doesn't clip the underlay to the inner viewport.
37+
style={{height: pageHeight}}
38+
className={classNames(underlayStyles, 'spectrum-Underlay', {
39+
'is-open': isOpen,
40+
'spectrum-Underlay--transparent': isTransparent
41+
})} />
42+
);
43+
}

0 commit comments

Comments
 (0)