mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
New Select: Fix the overflow issue and menu positioning (#22039)
* Fix the overflow issue and menu positioning * Add portal as a property
This commit is contained in:
parent
e9bc8afac8
commit
eba0390502
@ -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, ...
|
|||||||
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>({
|
|||||||
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';
|
|||||||
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 = () => {
|
|||||||
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';
|
|||||||
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
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
|||||||
|
|
||||||
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>> = [
|
|||||||
|
|
||||||
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', () => {
|
|||||||
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', () => {
|
|||||||
${'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', () => {
|
|||||||
|
|
||||||
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> {
|
|||||||
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
|
|||||||
|
|
||||||
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>({
|
|||||||
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>({
|
|||||||
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>({
|
|||||||
}}
|
}}
|
||||||
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';
|
|||||||
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) => {
|
|||||||
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';
|
|||||||
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);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user