Select: Portaling for Select (#22040)

This commit is contained in:
Dominik Prokop 2020-02-09 13:37:00 +01:00 committed by GitHub
parent eba0390502
commit 25431f32f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 114 additions and 108 deletions

View File

@ -74,7 +74,6 @@ export function ButtonSelect<T>({
return (
<SelectBase
{...selectProps}
portal={document.body}
renderControl={React.forwardRef<any, CustomControlProps<T>>(({ onBlur, onClick, value, isOpen }, ref) => {
return (
<SelectButton {...buttonProps} innerRef={ref} onBlur={onBlur} onClick={onClick} isOpen={isOpen}>

View File

@ -237,7 +237,6 @@ export const customizedControl = () => {
setValue(v);
}}
size="md"
portal={document.body}
renderControl={React.forwardRef(({ isOpen, value, ...otherProps }, ref) => {
return (
<Button {...otherProps} ref={ref}>

View File

@ -3,12 +3,12 @@ import { SelectableValue } from '@grafana/data';
import { SelectCommonProps, SelectBase, MultiSelectCommonProps, SelectAsyncProps } from './SelectBase';
export function Select<T>(props: SelectCommonProps<T>) {
return <SelectBase {...props} portal={props.portal || document.body} />;
return <SelectBase {...props} />;
}
export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
// @ts-ignore
return <SelectBase {...props} portal={props.portal || document.body} isMulti />;
return <SelectBase {...props} isMulti />;
}
interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
@ -17,7 +17,7 @@ interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, Sel
}
export function AsyncSelect<T>(props: AsyncSelectProps<T>) {
return <SelectBase {...props} portal={props.portal || document.body} />;
return <SelectBase {...props} />;
}
interface AsyncMultiSelectProps<T> extends Omit<MultiSelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
@ -27,5 +27,5 @@ interface AsyncMultiSelectProps<T> extends Omit<MultiSelectCommonProps<T>, 'opti
export function AsyncMultiSelect<T>(props: AsyncMultiSelectProps<T>) {
// @ts-ignore
return <SelectBase {...props} portal={props.portal || document.body} isMulti />;
return <SelectBase {...props} isMulti />;
}

View File

@ -18,11 +18,11 @@ const options: Array<SelectableValue<number>> = [
describe('SelectBase', () => {
it('renders without error', () => {
mount(<SelectBase portal={null} onChange={onChangeHandler} />);
mount(<SelectBase onChange={onChangeHandler} />);
});
it('renders empty options information', () => {
const container = mount(<SelectBase portal={null} onChange={onChangeHandler} isOpen />);
const container = mount(<SelectBase onChange={onChangeHandler} isOpen />);
const noopt = container.find({ 'aria-label': 'No options provided' });
expect(noopt).toHaveLength(1);
});
@ -30,7 +30,7 @@ describe('SelectBase', () => {
describe('when openMenuOnFocus prop', () => {
describe('is provided', () => {
it('opens on focus', () => {
const container = mount(<SelectBase portal={null} onChange={onChangeHandler} openMenuOnFocus />);
const container = mount(<SelectBase onChange={onChangeHandler} openMenuOnFocus />);
container.find('input').simulate('focus');
const menu = findMenuElement(container);
@ -44,7 +44,7 @@ describe('SelectBase', () => {
${'ArrowUp'}
${' '}
`('opens on arrow down/up or space', ({ key }) => {
const container = mount(<SelectBase portal={null} onChange={onChangeHandler} />);
const container = mount(<SelectBase onChange={onChangeHandler} />);
const input = container.find('input');
input.simulate('focus');
input.simulate('keydown', { key });
@ -56,14 +56,14 @@ describe('SelectBase', () => {
describe('options', () => {
it('renders menu with provided options', () => {
const container = mount(<SelectBase portal={null} options={options} onChange={onChangeHandler} isOpen />);
const container = mount(<SelectBase options={options} onChange={onChangeHandler} isOpen />);
const menuOptions = container.find({ 'aria-label': 'Select option' });
expect(menuOptions).toHaveLength(2);
});
it('call onChange handler when option is selected', () => {
const spy = jest.fn();
const handler = (value: SelectableValue<number>) => spy(value);
const container = mount(<SelectBase portal={null} options={options} onChange={handler} isOpen />);
const container = mount(<SelectBase options={options} onChange={handler} isOpen />);
const menuOptions = container.find({ 'aria-label': 'Select option' });
expect(menuOptions).toHaveLength(2);
const menuOption = menuOptions.first();

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { SelectableValue, deprecationWarning } from '@grafana/data';
// @ts-ignore
import { default as ReactSelect } from '@torkelo/react-select';
@ -24,6 +24,7 @@ import { SingleValue } from './SingleValue';
import { MultiValueContainer, MultiValueRemove } from './MultiValue';
import { useTheme } from '../../../themes';
import { getSelectStyles } from './getSelectStyles';
import { RefForwardingPortal } from '../../Portal/Portal';
type SelectValue<T> = T | SelectableValue<T> | T[] | Array<SelectableValue<T>>;
@ -64,8 +65,6 @@ export interface SelectCommonProps<T> {
prefix?: JSX.Element | string | null;
/** Use a custom element to control Select. A proper ref to the renderControl is needed if 'portal' isn't set to null*/
renderControl?: ControlComponent<T>;
/** An element where the dropdown menu should be rendered. In all Select implementations it defaults to document.body .*/
portal?: HTMLElement | null;
}
export interface SelectAsyncProps<T> {
@ -84,7 +83,6 @@ export interface MultiSelectCommonProps<T> extends Omit<SelectCommonProps<T>, 'o
export interface SelectBaseProps<T> extends SelectCommonProps<T>, SelectAsyncProps<T> {
invalid?: boolean;
portal: HTMLElement | null;
}
export interface CustomControlProps<T> {
@ -177,10 +175,10 @@ export function SelectBase<T>({
renderControl,
width,
invalid,
portal,
components,
}: SelectBaseProps<T>) {
const theme = useTheme();
const portalRef = useRef<HTMLDivElement>();
const styles = getSelectStyles(theme);
let ReactSelectComponent: ReactSelect | Creatable = ReactSelect;
const creatableProps: any = {};
@ -238,7 +236,7 @@ export function SelectBase<T>({
renderControl,
captureMenuScroll: false,
blurInputOnSelect: true,
menuPortalTarget: portal,
menuPortalTarget: portalRef.current,
menuPlacement: 'auto',
};
@ -265,95 +263,98 @@ export function SelectBase<T>({
}
return (
<ReactSelectComponent
components={{
MenuList: SelectMenu,
Group: SelectOptionGroup,
ValueContainer: ValueContainer,
Placeholder: (props: any) => (
<div
{...props.innerProps}
className={cx(
css(props.getStyles('placeholder', props)),
css`
display: inline-block;
color: hsl(0, 0%, 50%);
position: absolute;
top: 50%;
transform: translateY(-50%);
box-sizing: border-box;
line-height: 1;
`
)}
>
{props.children}
</div>
),
SelectContainer: (props: any) => (
<div
{...props.innerProps}
className={cx(
css(props.getStyles('container', props)),
css`
position: relative;
`,
inputSizes()[size]
)}
>
{props.children}
</div>
),
IndicatorsContainer: IndicatorsContainer,
IndicatorSeparator: () => <></>,
Control: CustomControl,
Option: SelectMenuOptions,
ClearIndicator: (props: any) => {
const { clearValue } = props;
return (
<Icon
name="times"
onMouseDown={e => {
e.preventDefault();
e.stopPropagation();
clearValue();
}}
/>
);
},
LoadingIndicator: (props: any) => {
return <Icon name="spinner" className="fa fa-spin" />;
},
LoadingMessage: (props: any) => {
return <div className={styles.loadingMessage}>{loadingMessage}</div>;
},
NoOptionsMessage: (props: any) => {
return (
<div className={styles.loadingMessage} aria-label="No options provided">
{noOptionsMessage}
<>
<ReactSelectComponent
components={{
MenuList: SelectMenu,
Group: SelectOptionGroup,
ValueContainer: ValueContainer,
Placeholder: (props: any) => (
<div
{...props.innerProps}
className={cx(
css(props.getStyles('placeholder', props)),
css`
display: inline-block;
color: hsl(0, 0%, 50%);
position: absolute;
top: 50%;
transform: translateY(-50%);
box-sizing: border-box;
line-height: 1;
`
)}
>
{props.children}
</div>
);
},
DropdownIndicator: (props: any) => <DropdownIndicator isOpen={props.selectProps.menuIsOpen} />,
SingleValue: SingleValue,
MultiValueContainer: MultiValueContainer,
MultiValueRemove: MultiValueRemove,
...components,
}}
styles={{
...resetSelectStyles(),
//These are required for the menu positioning to function
menu: ({ top, bottom, width, position }: any) => ({
top,
bottom,
width,
position,
marginBottom: !!bottom ? '10px' : '0',
}),
}}
className={widthClass}
{...commonSelectProps}
{...creatableProps}
{...asyncSelectProps}
/>
),
SelectContainer: (props: any) => (
<div
{...props.innerProps}
className={cx(
css(props.getStyles('container', props)),
css`
position: relative;
`,
inputSizes()[size]
)}
>
{props.children}
</div>
),
IndicatorsContainer: IndicatorsContainer,
IndicatorSeparator: () => <></>,
Control: CustomControl,
Option: SelectMenuOptions,
ClearIndicator: (props: any) => {
const { clearValue } = props;
return (
<Icon
name="times"
onMouseDown={e => {
e.preventDefault();
e.stopPropagation();
clearValue();
}}
/>
);
},
LoadingIndicator: (props: any) => {
return <Icon name="spinner" className="fa fa-spin" />;
},
LoadingMessage: (props: any) => {
return <div className={styles.loadingMessage}>{loadingMessage}</div>;
},
NoOptionsMessage: (props: any) => {
return (
<div className={styles.loadingMessage} aria-label="No options provided">
{noOptionsMessage}
</div>
);
},
DropdownIndicator: (props: any) => <DropdownIndicator isOpen={props.selectProps.menuIsOpen} />,
SingleValue: SingleValue,
MultiValueContainer: MultiValueContainer,
MultiValueRemove: MultiValueRemove,
...components,
}}
styles={{
...resetSelectStyles(),
//These are required for the menu positioning to function
menu: ({ top, bottom, width, position }: any) => ({
top,
bottom,
width,
position,
marginBottom: !!bottom ? '10px' : '0',
}),
}}
className={widthClass}
{...commonSelectProps}
{...creatableProps}
{...asyncSelectProps}
/>
<RefForwardingPortal ref={portalRef as any} />
</>
);
}

View File

@ -4,6 +4,7 @@ import ReactDOM from 'react-dom';
interface Props {
className?: string;
root?: HTMLElement;
forwardedRef?: any;
}
export class Portal extends PureComponent<Props> {
@ -29,8 +30,14 @@ export class Portal extends PureComponent<Props> {
render() {
// Default z-index is high to make sure
return ReactDOM.createPortal(
<div style={{ zIndex: 1051, position: 'relative' }}>{this.props.children}</div>,
<div style={{ zIndex: 1051, position: 'relative' }} ref={this.props.forwardedRef}>
{this.props.children}
</div>,
this.node
);
}
}
export const RefForwardingPortal = React.forwardRef<HTMLDivElement, Props>((props, ref) => {
return <Portal {...props} forwardedRef={ref} />;
});