mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
beb84384da
commit
dcdd334663
@ -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"]
|
||||
],
|
||||
|
@ -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>(
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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}`,
|
||||
|
Loading…
Reference in New Issue
Block a user