RolePicker: Use portal to render menu (#77499)

* RolePicker: Use portal for menu

* Remove logging

* Fix submenu styles

* Fix ROLE_PICKER_MAX_MENU_WIDTH calculation

* Fix first menu open glitch

* Fix menu closing on ckick

* Fix menu position
This commit is contained in:
Alexander Zobnin 2023-11-03 10:06:51 +01:00 committed by GitHub
parent beb84384da
commit dcdd334663
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 86 additions and 50 deletions

View File

@ -1407,9 +1407,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"]
],
"public/app/core/components/RolePicker/RolePickerMenu.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/core/components/RolePicker/ValueContainer.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],

View File

@ -26,7 +26,7 @@ interface RoleMenuGroupsSectionProps {
selectedOptions: Role[];
onRoleChange: (option: Role) => void;
onClearSubMenu: (group: string) => void;
showOnLeftSubMenu: boolean;
showOnLeftSubMenu?: boolean;
}
export const RoleMenuGroupsSection = React.forwardRef<HTMLDivElement, RoleMenuGroupsSectionProps>(

View File

@ -1,11 +1,11 @@
import React, { FormEvent, useCallback, useEffect, useState, useRef } from 'react';
import { ClickOutsideWrapper, useTheme2 } from '@grafana/ui';
import { ClickOutsideWrapper, Portal, useTheme2 } from '@grafana/ui';
import { Role, OrgRole } from 'app/types';
import { RolePickerInput } from './RolePickerInput';
import { RolePickerMenu } from './RolePickerMenu';
import { MENU_MAX_HEIGHT, ROLE_PICKER_SUBMENU_MIN_WIDTH, ROLE_PICKER_WIDTH } from './constants';
import { MENU_MAX_HEIGHT, ROLE_PICKER_MAX_MENU_WIDTH, ROLE_PICKER_WIDTH } from './constants';
export interface Props {
basicRole?: OrgRole;
@ -48,6 +48,7 @@ export const RolePicker = ({
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole | undefined>(basicRole);
const [query, setQuery] = useState('');
const [offset, setOffset] = useState({ vertical: 0, horizontal: 0 });
const [menuLeft, setMenuLeft] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const theme = useTheme2();
const widthPx = typeof width === 'number' ? theme.spacing(width) : width;
@ -57,22 +58,36 @@ export const RolePicker = ({
setSelectedRoles(appliedRoles);
}, [appliedRoles, basicRole, onBasicRoleChange]);
const setMenuPosition = useCallback(() => {
const { horizontal, vertical, menuToLeft } = calculateMenuPosition();
if (horizontal && vertical) {
setOffset({ horizontal, vertical });
setMenuLeft(menuToLeft);
}
}, []);
useEffect(() => {
const dimensions = ref?.current?.getBoundingClientRect();
if (!dimensions || !isOpen) {
if (!isOpen) {
return;
}
const { bottom, top, left, right, width: currentRolePickerWidth } = dimensions;
const distance = window.innerHeight - bottom;
const offsetVertical = bottom - top + 10; // Add extra 10px to offset to account for border and outline
const offsetHorizontal = right - left;
let horizontal = -offsetHorizontal;
let vertical = -offsetVertical;
setMenuPosition();
}, [isOpen, selectedRoles, setMenuPosition]);
const calculateMenuPosition = () => {
const dimensions = ref?.current?.getBoundingClientRect();
if (!dimensions) {
return {};
}
const { bottom, top, left, right } = dimensions;
let horizontal = left;
let vertical = bottom + 10; // Add extra 10px to offset to account for border and outline
let menuToLeft = false;
const distance = window.innerHeight - bottom;
if (distance < MENU_MAX_HEIGHT + 20) {
// Off set to display the role picker menu at the bottom of the screen
// without resorting to scroll the page
vertical = 50 + (MENU_MAX_HEIGHT - distance) - offsetVertical;
vertical = top - MENU_MAX_HEIGHT - 50;
}
/*
@ -82,25 +97,24 @@ export const RolePicker = ({
* both (the role picker menu and its sub menu) aligned to the left edge of the input.
* Otherwise, it aligns the role picker menu to the right.
*/
if (
window.innerWidth - right < currentRolePickerWidth &&
currentRolePickerWidth < 2 * ROLE_PICKER_SUBMENU_MIN_WIDTH
) {
horizontal = offsetHorizontal;
if (left + ROLE_PICKER_MAX_MENU_WIDTH > window.innerWidth) {
horizontal = window.innerWidth - right;
menuToLeft = true;
}
setOffset({ horizontal, vertical });
}, [isOpen, selectedRoles]);
return { horizontal, vertical, menuToLeft };
};
const onOpen = useCallback(
(event: FormEvent<HTMLElement>) => {
if (!disabled) {
event.preventDefault();
event.stopPropagation();
setMenuPosition();
setOpen(true);
}
},
[setOpen, disabled]
[disabled, setMenuPosition]
);
const onClose = useCallback(() => {
@ -160,7 +174,7 @@ export const RolePicker = ({
}}
ref={ref}
>
<ClickOutsideWrapper onClick={onClickOutside} useCapture={true}>
<ClickOutsideWrapper onClick={onClickOutside} useCapture={false}>
<RolePickerInput
basicRole={selectedBuiltInRole}
appliedRoles={selectedRoles}
@ -175,21 +189,29 @@ export const RolePicker = ({
isLoading={isLoading}
/>
{isOpen && (
<RolePickerMenu
options={getOptions()}
basicRole={selectedBuiltInRole}
appliedRoles={appliedRoles}
onBasicRoleSelect={onBasicRoleSelect}
onSelect={onSelect}
onUpdate={onUpdate}
showGroups={query.length === 0 || query.trim() === ''}
basicRoleDisabled={basicRoleDisabled}
disabledMessage={basicRoleDisabledMessage}
showBasicRole={showBasicRole}
updateDisabled={basicRoleDisabled && !canUpdateRoles}
apply={apply}
offset={offset}
/>
<Portal>
{/* Since menu rendered in portal and whole component wrapped in ClickOutsideWrapper, */}
{/* we need to stop event propagation to prevent closing menu */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={(e) => e.stopPropagation()}>
<RolePickerMenu
options={getOptions()}
basicRole={selectedBuiltInRole}
appliedRoles={appliedRoles}
onBasicRoleSelect={onBasicRoleSelect}
onSelect={onSelect}
onUpdate={onUpdate}
showGroups={query.length === 0 || query.trim() === ''}
basicRoleDisabled={basicRoleDisabled}
disabledMessage={basicRoleDisabledMessage}
showBasicRole={showBasicRole}
updateDisabled={basicRoleDisabled && !canUpdateRoles}
apply={apply}
offset={offset}
menuLeft={menuLeft}
/>
</div>
</Portal>
)}
</ClickOutsideWrapper>
</div>

View File

@ -63,6 +63,7 @@ interface RolePickerMenuProps {
updateDisabled?: boolean;
apply?: boolean;
offset: { vertical: number; horizontal: number };
menuLeft?: boolean;
}
export const RolePickerMenu = ({
@ -78,6 +79,7 @@ export const RolePickerMenu = ({
onUpdate,
updateDisabled,
offset,
menuLeft,
apply,
}: RolePickerMenuProps): JSX.Element => {
const [selectedOptions, setSelectedOptions] = useState<Role[]>(appliedRoles);
@ -206,11 +208,12 @@ export const RolePickerMenu = ({
className={cx(
styles.menu,
customStyles.menuWrapper,
{ [customStyles.menuLeft]: offset.horizontal > 0 },
css`
bottom: ${offset.vertical > 0 ? `${offset.vertical}px` : 'unset'};
top: ${offset.vertical < 0 ? `${Math.abs(offset.vertical)}px` : 'unset'};
`
{ [customStyles.menuLeft]: menuLeft },
css({
top: `${offset.vertical}px`,
left: !menuLeft ? `${offset.horizontal}px` : 'unset',
right: menuLeft ? `${offset.horizontal}px` : 'unset',
})
)}
>
<div className={customStyles.menu} aria-label="Role picker menu">
@ -248,7 +251,7 @@ export const RolePickerMenu = ({
selectedOptions={selectedOptions}
onRoleChange={onChange}
onClearSubMenu={onClearSubMenu}
showOnLeftSubMenu={offset.horizontal > 0}
showOnLeftSubMenu={menuLeft}
/>
))}
</CustomScrollbar>

View File

@ -1,3 +1,11 @@
export const MENU_MAX_HEIGHT = 300; // max height for the picker's dropdown menu
export const ROLE_PICKER_WIDTH = 360;
export const ROLE_PICKER_SUBMENU_MIN_WIDTH = 260;
export const MENU_MAX_HEIGHT = 300; // max height for the picker's dropdown menu
export const ROLE_PICKER_MENU_MIN_WIDTH = 320;
export const ROLE_PICKER_MENU_MAX_WIDTH = 360;
export const ROLE_PICKER_SUBMENU_MIN_WIDTH = 320;
export const ROLE_PICKER_SUBMENU_MAX_WIDTH = 360;
export const ROLE_PICKER_MAX_MENU_WIDTH = ROLE_PICKER_MENU_MAX_WIDTH + ROLE_PICKER_SUBMENU_MAX_WIDTH;

View File

@ -2,7 +2,12 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { ROLE_PICKER_SUBMENU_MIN_WIDTH } from './constants';
import {
ROLE_PICKER_MENU_MAX_WIDTH,
ROLE_PICKER_MENU_MIN_WIDTH,
ROLE_PICKER_SUBMENU_MAX_WIDTH,
ROLE_PICKER_SUBMENU_MIN_WIDTH,
} from './constants';
export const getStyles = (theme: GrafanaTheme2) => ({
hideScrollBar: css({
@ -24,18 +29,19 @@ export const getStyles = (theme: GrafanaTheme2) => ({
minWidth: 'auto',
}),
menu: css({
minWidth: `${ROLE_PICKER_SUBMENU_MIN_WIDTH}px`,
minWidth: `${ROLE_PICKER_MENU_MIN_WIDTH}px`,
maxWidth: `${ROLE_PICKER_MENU_MAX_WIDTH}px`,
'& > div': {
paddingTop: theme.spacing(1),
},
}),
menuLeft: css({
right: 0,
flexDirection: 'row-reverse',
}),
subMenu: css({
height: '100%',
minWidth: `${ROLE_PICKER_SUBMENU_MIN_WIDTH}px`,
maxWidth: `${ROLE_PICKER_SUBMENU_MAX_WIDTH}px`,
display: 'flex',
flexDirection: 'column',
borderLeft: `1px solid ${theme.components.input.borderColor}`,