Commit eba03905 by Tobias Skarhed Committed by GitHub

New Select: Fix the overflow issue and menu positioning (#22039)

* Fix the overflow issue and menu positioning

* Add portal as a property
parent e9bc8afa
...@@ -17,9 +17,10 @@ interface ButtonSelectProps<T> extends Omit<SelectCommonProps<T>, 'renderControl ...@@ -17,9 +17,10 @@ interface ButtonSelectProps<T> extends Omit<SelectCommonProps<T>, 'renderControl
interface SelectButtonProps extends Omit<ButtonProps, 'icon'> { interface SelectButtonProps extends Omit<ButtonProps, 'icon'> {
icon?: IconType; icon?: IconType;
isOpen?: boolean; isOpen?: boolean;
innerRef: any;
} }
const SelectButton: React.FC<SelectButtonProps> = ({ icon, children, isOpen, ...buttonProps }) => { const SelectButton: React.FC<SelectButtonProps> = ({ icon, children, isOpen, innerRef, ...buttonProps }) => {
const theme = useTheme(); const theme = useTheme();
const styles = { const styles = {
wrapper: css` wrapper: css`
...@@ -42,7 +43,7 @@ const SelectButton: React.FC<SelectButtonProps> = ({ icon, children, isOpen, ... ...@@ -42,7 +43,7 @@ const SelectButton: React.FC<SelectButtonProps> = ({ icon, children, isOpen, ...
const buttonIcon = `fa fa-${icon}`; const buttonIcon = `fa fa-${icon}`;
const caretIcon = isOpen ? 'caret-up' : 'caret-down'; const caretIcon = isOpen ? 'caret-up' : 'caret-down';
return ( return (
<Button {...buttonProps} icon={buttonIcon}> <Button {...buttonProps} ref={innerRef} icon={buttonIcon}>
<span className={styles.wrapper}> <span className={styles.wrapper}>
<span>{children}</span> <span>{children}</span>
<span className={styles.caretWrap}> <span className={styles.caretWrap}>
...@@ -73,9 +74,10 @@ export function ButtonSelect<T>({ ...@@ -73,9 +74,10 @@ export function ButtonSelect<T>({
return ( return (
<SelectBase <SelectBase
{...selectProps} {...selectProps}
renderControl={React.forwardRef<any, CustomControlProps<T>>(({ onBlur, onClick, value, isOpen }, _ref) => { portal={document.body}
renderControl={React.forwardRef<any, CustomControlProps<T>>(({ onBlur, onClick, value, isOpen }, ref) => {
return ( return (
<SelectButton {...buttonProps} onBlur={onBlur} onClick={onClick} isOpen={isOpen}> <SelectButton {...buttonProps} innerRef={ref} onBlur={onBlur} onClick={onClick} isOpen={isOpen}>
{value ? value.label : placeholder} {value ? value.label : placeholder}
</SelectButton> </SelectButton>
); );
......
...@@ -11,7 +11,7 @@ import { getIconKnob } from '../../../utils/storybook/knobs'; ...@@ -11,7 +11,7 @@ import { getIconKnob } from '../../../utils/storybook/knobs';
import kebabCase from 'lodash/kebabCase'; import kebabCase from 'lodash/kebabCase';
export default { export default {
title: 'General/Select', title: 'Forms/Select',
component: Select, component: Select,
decorators: [withCenteredStory, withHorizontallyCenteredStory], decorators: [withCenteredStory, withHorizontallyCenteredStory],
}; };
...@@ -237,14 +237,40 @@ export const customizedControl = () => { ...@@ -237,14 +237,40 @@ export const customizedControl = () => {
setValue(v); setValue(v);
}} }}
size="md" size="md"
renderControl={({ isOpen, value, ...otherProps }) => { portal={document.body}
return <Button {...otherProps}> {isOpen ? 'Open' : 'Closed'}</Button>; renderControl={React.forwardRef(({ isOpen, value, ...otherProps }, ref) => {
}} return (
<Button {...otherProps} ref={ref}>
{' '}
{isOpen ? 'Open' : 'Closed'}
</Button>
);
})}
{...getDynamicProps()} {...getDynamicProps()}
/> />
); );
}; };
export const autoMenuPlacement = () => {
const [value, setValue] = useState<SelectableValue<string>>();
return (
<>
<div style={{ height: '95vh', display: 'flex', alignItems: 'flex-end' }}>
<Select
options={generateOptions()}
value={value}
onChange={v => {
setValue(v);
}}
size="md"
{...getDynamicProps()}
/>
</div>
</>
);
};
export const customValueCreation = () => { export const customValueCreation = () => {
const [value, setValue] = useState<SelectableValue<string>>(); const [value, setValue] = useState<SelectableValue<string>>();
const [customOptions, setCustomOptions] = useState<Array<SelectableValue<string>>>([]); const [customOptions, setCustomOptions] = useState<Array<SelectableValue<string>>>([]);
......
...@@ -3,12 +3,12 @@ import { SelectableValue } from '@grafana/data'; ...@@ -3,12 +3,12 @@ import { SelectableValue } from '@grafana/data';
import { SelectCommonProps, SelectBase, MultiSelectCommonProps, SelectAsyncProps } from './SelectBase'; import { SelectCommonProps, SelectBase, MultiSelectCommonProps, SelectAsyncProps } from './SelectBase';
export function Select<T>(props: SelectCommonProps<T>) { export function Select<T>(props: SelectCommonProps<T>) {
return <SelectBase {...props} />; return <SelectBase {...props} portal={props.portal || document.body} />;
} }
export function MultiSelect<T>(props: MultiSelectCommonProps<T>) { export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
// @ts-ignore // @ts-ignore
return <SelectBase {...props} isMulti />; return <SelectBase {...props} portal={props.portal || document.body} isMulti />;
} }
interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> { interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
...@@ -17,7 +17,7 @@ interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, Sel ...@@ -17,7 +17,7 @@ interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, Sel
} }
export function AsyncSelect<T>(props: AsyncSelectProps<T>) { export function AsyncSelect<T>(props: AsyncSelectProps<T>) {
return <SelectBase {...props} />; return <SelectBase {...props} portal={props.portal || document.body} />;
} }
interface AsyncMultiSelectProps<T> extends Omit<MultiSelectCommonProps<T>, 'options'>, SelectAsyncProps<T> { interface AsyncMultiSelectProps<T> extends Omit<MultiSelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
...@@ -27,5 +27,5 @@ interface AsyncMultiSelectProps<T> extends Omit<MultiSelectCommonProps<T>, 'opti ...@@ -27,5 +27,5 @@ interface AsyncMultiSelectProps<T> extends Omit<MultiSelectCommonProps<T>, 'opti
export function AsyncMultiSelect<T>(props: AsyncMultiSelectProps<T>) { export function AsyncMultiSelect<T>(props: AsyncMultiSelectProps<T>) {
// @ts-ignore // @ts-ignore
return <SelectBase {...props} isMulti />; return <SelectBase {...props} portal={props.portal || document.body} isMulti />;
} }
...@@ -18,11 +18,11 @@ const options: Array<SelectableValue<number>> = [ ...@@ -18,11 +18,11 @@ const options: Array<SelectableValue<number>> = [
describe('SelectBase', () => { describe('SelectBase', () => {
it('renders without error', () => { it('renders without error', () => {
mount(<SelectBase onChange={onChangeHandler} />); mount(<SelectBase portal={null} onChange={onChangeHandler} />);
}); });
it('renders empty options information', () => { it('renders empty options information', () => {
const container = mount(<SelectBase onChange={onChangeHandler} isOpen />); const container = mount(<SelectBase portal={null} onChange={onChangeHandler} isOpen />);
const noopt = container.find({ 'aria-label': 'No options provided' }); const noopt = container.find({ 'aria-label': 'No options provided' });
expect(noopt).toHaveLength(1); expect(noopt).toHaveLength(1);
}); });
...@@ -30,7 +30,7 @@ describe('SelectBase', () => { ...@@ -30,7 +30,7 @@ describe('SelectBase', () => {
describe('when openMenuOnFocus prop', () => { describe('when openMenuOnFocus prop', () => {
describe('is provided', () => { describe('is provided', () => {
it('opens on focus', () => { it('opens on focus', () => {
const container = mount(<SelectBase onChange={onChangeHandler} openMenuOnFocus />); const container = mount(<SelectBase portal={null} onChange={onChangeHandler} openMenuOnFocus />);
container.find('input').simulate('focus'); container.find('input').simulate('focus');
const menu = findMenuElement(container); const menu = findMenuElement(container);
...@@ -44,7 +44,7 @@ describe('SelectBase', () => { ...@@ -44,7 +44,7 @@ describe('SelectBase', () => {
${'ArrowUp'} ${'ArrowUp'}
${' '} ${' '}
`('opens on arrow down/up or space', ({ key }) => { `('opens on arrow down/up or space', ({ key }) => {
const container = mount(<SelectBase onChange={onChangeHandler} />); const container = mount(<SelectBase portal={null} onChange={onChangeHandler} />);
const input = container.find('input'); const input = container.find('input');
input.simulate('focus'); input.simulate('focus');
input.simulate('keydown', { key }); input.simulate('keydown', { key });
...@@ -56,14 +56,14 @@ describe('SelectBase', () => { ...@@ -56,14 +56,14 @@ describe('SelectBase', () => {
describe('options', () => { describe('options', () => {
it('renders menu with provided options', () => { it('renders menu with provided options', () => {
const container = mount(<SelectBase options={options} onChange={onChangeHandler} isOpen />); const container = mount(<SelectBase portal={null} options={options} onChange={onChangeHandler} isOpen />);
const menuOptions = container.find({ 'aria-label': 'Select option' }); const menuOptions = container.find({ 'aria-label': 'Select option' });
expect(menuOptions).toHaveLength(2); expect(menuOptions).toHaveLength(2);
}); });
it('call onChange handler when option is selected', () => { it('call onChange handler when option is selected', () => {
const spy = jest.fn(); const spy = jest.fn();
const handler = (value: SelectableValue<number>) => spy(value); const handler = (value: SelectableValue<number>) => spy(value);
const container = mount(<SelectBase options={options} onChange={handler} isOpen />); const container = mount(<SelectBase portal={null} options={options} onChange={handler} isOpen />);
const menuOptions = container.find({ 'aria-label': 'Select option' }); const menuOptions = container.find({ 'aria-label': 'Select option' });
expect(menuOptions).toHaveLength(2); expect(menuOptions).toHaveLength(2);
const menuOption = menuOptions.first(); const menuOption = menuOptions.first();
......
...@@ -62,7 +62,10 @@ export interface SelectCommonProps<T> { ...@@ -62,7 +62,10 @@ export interface SelectCommonProps<T> {
size?: FormInputSize; size?: FormInputSize;
/** item to be rendered in front of the input */ /** item to be rendered in front of the input */
prefix?: JSX.Element | string | null; 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>; 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> { export interface SelectAsyncProps<T> {
...@@ -81,6 +84,7 @@ export interface MultiSelectCommonProps<T> extends Omit<SelectCommonProps<T>, 'o ...@@ -81,6 +84,7 @@ export interface MultiSelectCommonProps<T> extends Omit<SelectCommonProps<T>, 'o
export interface SelectBaseProps<T> extends SelectCommonProps<T>, SelectAsyncProps<T> { export interface SelectBaseProps<T> extends SelectCommonProps<T>, SelectAsyncProps<T> {
invalid?: boolean; invalid?: boolean;
portal: HTMLElement | null;
} }
export interface CustomControlProps<T> { export interface CustomControlProps<T> {
...@@ -173,6 +177,7 @@ export function SelectBase<T>({ ...@@ -173,6 +177,7 @@ export function SelectBase<T>({
renderControl, renderControl,
width, width,
invalid, invalid,
portal,
components, components,
}: SelectBaseProps<T>) { }: SelectBaseProps<T>) {
const theme = useTheme(); const theme = useTheme();
...@@ -233,6 +238,8 @@ export function SelectBase<T>({ ...@@ -233,6 +238,8 @@ export function SelectBase<T>({
renderControl, renderControl,
captureMenuScroll: false, captureMenuScroll: false,
blurInputOnSelect: true, blurInputOnSelect: true,
menuPortalTarget: portal,
menuPlacement: 'auto',
}; };
// width property is deprecated in favor of size or className // width property is deprecated in favor of size or className
...@@ -334,6 +341,14 @@ export function SelectBase<T>({ ...@@ -334,6 +341,14 @@ export function SelectBase<T>({
}} }}
styles={{ styles={{
...resetSelectStyles(), ...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} className={widthClass}
{...commonSelectProps} {...commonSelectProps}
......
...@@ -8,15 +8,16 @@ import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar'; ...@@ -8,15 +8,16 @@ import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
interface SelectMenuProps { interface SelectMenuProps {
maxHeight: number; maxHeight: number;
innerRef: React.Ref<any>; innerRef: React.Ref<any>;
innerProps: {};
} }
export const SelectMenu = React.forwardRef<HTMLDivElement, React.PropsWithChildren<SelectMenuProps>>((props, ref) => { export const SelectMenu = React.forwardRef<HTMLDivElement, React.PropsWithChildren<SelectMenuProps>>((props, ref) => {
const theme = useTheme(); const theme = useTheme();
const styles = getSelectStyles(theme); const styles = getSelectStyles(theme);
const { children, maxHeight, innerRef } = props; const { children, maxHeight, innerRef, innerProps } = props;
return ( return (
<div className={styles.menu} ref={innerRef} style={{ maxHeight }} aria-label="Select options menu"> <div {...innerProps} className={styles.menu} ref={innerRef} style={{ maxHeight }} aria-label="Select options menu">
<CustomScrollbar autoHide={false} autoHeightMax="inherit" hideHorizontalTrack> <CustomScrollbar autoHide={false} autoHeightMax="inherit" hideHorizontalTrack>
{children} {children}
</CustomScrollbar> </CustomScrollbar>
......
...@@ -14,7 +14,7 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme) => { ...@@ -14,7 +14,7 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme) => {
menu: css` menu: css`
background: ${bgColor}; background: ${bgColor};
box-shadow: 0px 4px 4px ${menuShadowColor}; box-shadow: 0px 4px 4px ${menuShadowColor};
position: absolute; position: relative;
min-width: 100%; min-width: 100%;
`, `,
option: css` option: css`
......
...@@ -7,7 +7,7 @@ import { UseState } from '../../utils/storybook/UseState'; ...@@ -7,7 +7,7 @@ import { UseState } from '../../utils/storybook/UseState';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { ButtonSelect } from './ButtonSelect'; import { ButtonSelect } from './ButtonSelect';
const ButtonSelectStories = storiesOf('Panel/Select/ButtonSelect', module); const ButtonSelectStories = storiesOf('General/Select/ButtonSelect', module);
ButtonSelectStories.addDecorator(withCenteredStory).addDecorator(withKnobs); ButtonSelectStories.addDecorator(withCenteredStory).addDecorator(withKnobs);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment