mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Forms: revamped select (#21092)
This commit is contained in:
parent
eac66813ca
commit
630f75d3ef
@ -29,6 +29,7 @@
|
||||
"@grafana/slate-react": "0.22.9-grafana",
|
||||
"@torkelo/react-select": "2.1.1",
|
||||
"@types/react-color": "2.17.0",
|
||||
"@types/react-select": "2.0.15",
|
||||
"@types/slate": "0.47.1",
|
||||
"@types/slate-react": "0.22.5",
|
||||
"bizcharts": "^3.5.5",
|
||||
|
@ -4,13 +4,14 @@ import { Button, LinkButton } from './Button';
|
||||
import withPropsCombinations from 'react-storybook-addon-props-combinations';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { ThemeableCombinationsRowRenderer } from '../../utils/storybook/CombinationsRowRenderer';
|
||||
import { select, boolean } from '@storybook/addon-knobs';
|
||||
import { boolean } from '@storybook/addon-knobs';
|
||||
import { getIconKnob } from '../../utils/storybook/knobs';
|
||||
|
||||
const ButtonStories = storiesOf('UI/Button', module);
|
||||
|
||||
const defaultProps = {
|
||||
onClick: [action('Button clicked')],
|
||||
children: ['Click, click!'],
|
||||
children: ['Click click!'],
|
||||
};
|
||||
|
||||
const variants = {
|
||||
@ -35,15 +36,10 @@ ButtonStories.add('as button element', () => renderButtonStory(Button));
|
||||
ButtonStories.add('as link element', () => renderButtonStory(LinkButton));
|
||||
|
||||
ButtonStories.add('with icon', () => {
|
||||
const iconKnob = select(
|
||||
'Icon',
|
||||
{
|
||||
Plus: 'fa fa-plus',
|
||||
User: 'fa fa-user',
|
||||
Gear: 'fa fa-gear',
|
||||
Annotation: 'gicon gicon-annotation',
|
||||
},
|
||||
'fa fa-plus'
|
||||
);
|
||||
return withPropsCombinations(Button, { ...variants, ...defaultProps, icon: [iconKnob] }, combinationOptions)();
|
||||
const icon = getIconKnob();
|
||||
return withPropsCombinations(
|
||||
Button,
|
||||
{ ...variants, ...defaultProps, icon: [icon && `fa fa-${icon}`] },
|
||||
combinationOptions
|
||||
)();
|
||||
});
|
||||
|
@ -18,12 +18,13 @@ type CommonProps = {
|
||||
};
|
||||
|
||||
type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
export const Button: React.FunctionComponent<ButtonProps> = props => {
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const { size, variant, icon, children, className, styles: stylesProp, ...buttonProps } = props;
|
||||
|
||||
// Default this to 'button', otherwise html defaults to 'submit' which then submits any form it is in.
|
||||
buttonProps.type = buttonProps.type || 'button';
|
||||
|
||||
const styles: ButtonStyles =
|
||||
stylesProp ||
|
||||
getButtonStyles({
|
||||
@ -32,14 +33,14 @@ export const Button: React.FunctionComponent<ButtonProps> = props => {
|
||||
variant: variant || 'primary',
|
||||
});
|
||||
|
||||
const buttonClassName = cx(styles.button, icon && styles.buttonWithIcon, icon && !children && styles.iconButton);
|
||||
return (
|
||||
<button className={cx(styles.button, className)} {...buttonProps}>
|
||||
<ButtonContent iconClassName={styles.icon} className={styles.iconWrap} icon={icon}>
|
||||
{children}
|
||||
</ButtonContent>
|
||||
<button className={buttonClassName} {...buttonProps} ref={ref}>
|
||||
<ButtonContent icon={icon}>{children}</ButtonContent>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
type LinkButtonProps = CommonProps &
|
||||
@ -48,7 +49,8 @@ type LinkButtonProps = CommonProps &
|
||||
// disabled.
|
||||
disabled?: boolean;
|
||||
};
|
||||
export const LinkButton: React.FunctionComponent<LinkButtonProps> = props => {
|
||||
|
||||
export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>((props, ref) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const { size, variant, icon, children, className, styles: stylesProp, ...anchorProps } = props;
|
||||
const styles: ButtonStyles =
|
||||
@ -59,12 +61,11 @@ export const LinkButton: React.FunctionComponent<LinkButtonProps> = props => {
|
||||
variant: variant || 'primary',
|
||||
});
|
||||
|
||||
const buttonClassName = cx(styles.button, icon && styles.buttonWithIcon, icon && !children && styles.iconButton);
|
||||
return (
|
||||
<a className={cx(styles.button, className)} {...anchorProps}>
|
||||
<ButtonContent iconClassName={styles.icon} className={styles.iconWrap} icon={icon}>
|
||||
{children}
|
||||
</ButtonContent>
|
||||
<a className={buttonClassName} {...anchorProps} ref={ref}>
|
||||
<ButtonContent icon={icon}>{children}</ButtonContent>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
});
|
||||
LinkButton.displayName = 'LinkButton';
|
||||
|
@ -1,39 +1,44 @@
|
||||
import React from 'react';
|
||||
import cx from 'classnames';
|
||||
import { css } from 'emotion';
|
||||
import { stylesFactory } from '../../themes';
|
||||
import { stylesFactory, useTheme } from '../../themes';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
const getStyles = stylesFactory(() => ({
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
content: css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
height: 100%;
|
||||
`,
|
||||
|
||||
icon: css`
|
||||
& + * {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
icon?: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export function ButtonContent(props: Props) {
|
||||
const { icon, className, iconClassName, children } = props;
|
||||
const styles = getStyles();
|
||||
if (icon && children) {
|
||||
return (
|
||||
<span className={cx(styles.content, className)}>
|
||||
<i className={cx([icon, iconClassName])} />
|
||||
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (icon) {
|
||||
return (
|
||||
<span className={cx(styles.content, className)}>
|
||||
<i className={cx([icon, iconClassName])} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const { icon, children } = props;
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return <span className={styles.content}>{children}</span>;
|
||||
const iconElement = icon && (
|
||||
<span className={styles.icon}>
|
||||
<i className={icon} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={styles.content}>
|
||||
{iconElement}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Button renders correct html 1`] = `"<button class=\\"css-1ffx2x7-button\\" type=\\"button\\"><span class=\\"css-16tmax3 css-1dxly9g-button-icon-wrap\\"><i class=\\"fa fa-plus\\"></i> <span>Click me</span></span></button>"`;
|
||||
exports[`Button renders correct html 1`] = `"<button class=\\"css-1ffx2x7-button css-5383lb\\" type=\\"button\\"><span class=\\"css-1beih13\\"><span class=\\"css-xhe2zh\\"><i class=\\"fa fa-plus\\"></i></span><span>Click me</span></span></button>"`;
|
||||
|
||||
exports[`LinkButton renders correct html 1`] = `"<a class=\\"css-1ffx2x7-button\\"><span class=\\"css-16tmax3 css-1dxly9g-button-icon-wrap\\"><i class=\\"fa fa-plus\\"></i> <span>Click me</span></span></a>"`;
|
||||
exports[`LinkButton renders correct html 1`] = `"<a class=\\"css-1ffx2x7-button css-5383lb\\"><span class=\\"css-1beih13\\"><span class=\\"css-xhe2zh\\"><i class=\\"fa fa-plus\\"></i></span><span>Click me</span></span></a>"`;
|
||||
|
@ -120,10 +120,18 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StyleDep
|
||||
box-shadow: none;
|
||||
}
|
||||
`,
|
||||
buttonWithIcon: css`
|
||||
padding-left: ${theme.spacing.sm};
|
||||
`,
|
||||
// used for buttons with icon onlys
|
||||
iconButton: css`
|
||||
padding-right: 0;
|
||||
`,
|
||||
iconWrap: css`
|
||||
label: button-icon-wrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& + * {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -12,6 +12,8 @@ export interface StyleDeps {
|
||||
|
||||
export interface ButtonStyles {
|
||||
button: string;
|
||||
buttonWithIcon: string;
|
||||
iconButton: string;
|
||||
iconWrap: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { select, text } from '@storybook/addon-knobs';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { select, text } from '@storybook/addon-knobs';
|
||||
import { getIconKnob } from '../../utils/storybook/knobs';
|
||||
import { ButtonSize } from '../Button/types';
|
||||
import mdx from './Button.mdx';
|
||||
|
||||
@ -24,9 +25,10 @@ export const simple = () => {
|
||||
const variant = select('Variant', variants, 'primary');
|
||||
const size = select('Size', sizes, 'md');
|
||||
const buttonText = text('text', 'Button');
|
||||
const icon = getIconKnob();
|
||||
|
||||
return (
|
||||
<Button variant={variant as ButtonVariant} size={size as ButtonSize}>
|
||||
<Button variant={variant as ButtonVariant} size={size as ButtonSize} icon={icon && `fa fa-${icon}`}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
);
|
||||
|
@ -76,6 +76,7 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
|
||||
|
||||
// Need to do this because of mismatch between variants in standard buttons and here
|
||||
type StyleProps = Omit<StyleDeps, 'variant'> & { variant: ButtonVariant };
|
||||
|
||||
export const getButtonStyles = stylesFactory(({ theme, size, variant }: StyleProps) => {
|
||||
const { padding, fontSize, height } = getPropertiesForButtonSize(theme, size);
|
||||
const { background, borderColor, variantStyles } = getPropertiesForVariant(theme, variant);
|
||||
@ -83,7 +84,6 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StylePro
|
||||
return {
|
||||
button: cx(
|
||||
css`
|
||||
position: relative;
|
||||
label: button;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -111,10 +111,18 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StylePro
|
||||
${variantStyles}
|
||||
`
|
||||
),
|
||||
buttonWithIcon: css`
|
||||
padding-left: ${theme.spacing.sm};
|
||||
`,
|
||||
// used for buttons with icon only
|
||||
iconButton: css`
|
||||
padding-right: 0;
|
||||
`,
|
||||
iconWrap: css`
|
||||
label: button-icon-wrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& + * {
|
||||
margin-left: ${theme.spacing.sm};
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
@ -130,25 +138,25 @@ type CommonProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
export type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const Button = (props: ButtonProps) => {
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getButtonStyles({
|
||||
theme,
|
||||
size: props.size || 'md',
|
||||
variant: props.variant || 'primary',
|
||||
});
|
||||
return <DefaultButton {...props} styles={styles} />;
|
||||
};
|
||||
return <DefaultButton {...props} styles={styles} ref={ref} />;
|
||||
});
|
||||
|
||||
type ButtonLinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
export const LinkButton = (props: ButtonLinkProps) => {
|
||||
export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>((props, ref) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getButtonStyles({
|
||||
theme,
|
||||
size: props.size || 'md',
|
||||
variant: props.variant || 'primary',
|
||||
});
|
||||
return <DefaultLinkButton {...props} styles={styles} />;
|
||||
};
|
||||
return <DefaultLinkButton {...props} styles={styles} ref={ref} />;
|
||||
});
|
||||
|
@ -55,10 +55,15 @@ export const simple = () => {
|
||||
const addonAfter = <Button variant="secondary">Load</Button>;
|
||||
const addonBefore = <div style={{ display: 'flex', alignItems: 'center', padding: '5px' }}>Input</div>;
|
||||
const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP);
|
||||
const suffix = select('Suffix', prefixSuffixOpts, null, VISUAL_GROUP);
|
||||
let prefixEl: any = prefix;
|
||||
if (prefix && prefix.match(/icon-/g)) {
|
||||
prefixEl = <Icon name={prefix.replace(/icon-/g, '') as IconType} />;
|
||||
}
|
||||
let suffixEl: any = suffix;
|
||||
if (suffix && suffix.match(/icon-/g)) {
|
||||
suffixEl = <Icon name={suffix.replace(/icon-/g, '') as IconType} />;
|
||||
}
|
||||
|
||||
const CONTAINER_GROUP = 'Container options';
|
||||
// ---
|
||||
@ -80,6 +85,7 @@ export const simple = () => {
|
||||
disabled={disabled}
|
||||
invalid={invalid}
|
||||
prefix={prefixEl}
|
||||
suffix={suffixEl}
|
||||
loading={loading}
|
||||
addonBefore={before && addonBefore}
|
||||
addonAfter={after && addonAfter}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { FC, HTMLProps, ReactNode } from 'react';
|
||||
import React, { HTMLProps, ReactNode } from 'react';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
import { getFocusStyle, inputSizes, sharedInputStyle } from '../commonStyles';
|
||||
@ -12,6 +12,8 @@ export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'siz
|
||||
invalid?: boolean;
|
||||
/** Show an icon as a prefix in the input */
|
||||
prefix?: JSX.Element | string | null;
|
||||
/** Show an icon as a suffix in the input */
|
||||
suffix?: JSX.Element | string | null;
|
||||
/** Show a loading indicator as a suffix in the input */
|
||||
loading?: boolean;
|
||||
/** Add a component as an addon before the input */
|
||||
@ -110,6 +112,9 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
|
||||
&:not(:last-child) {
|
||||
padding-right: ${prefixSuffixStaticWidth};
|
||||
}
|
||||
&[readonly] {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@ -124,8 +129,6 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
|
||||
border-radius: ${borderRadius};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0 ${theme.spacing.sm} 0 ${theme.spacing.sm};
|
||||
font-size: ${theme.typography.size.md};
|
||||
|
||||
/*
|
||||
Restoring increase/decrease spinner on number inputs. Overwriting rules implemented in
|
||||
@ -143,6 +146,10 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
|
||||
}
|
||||
`
|
||||
),
|
||||
inputDisabled: css`
|
||||
background-color: ${colors.formInputBgDisabled};
|
||||
color: ${colors.formInputDisabledText};
|
||||
`,
|
||||
addon: css`
|
||||
label: input-addon;
|
||||
display: flex;
|
||||
@ -184,7 +191,6 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
|
||||
border-right: none;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
left: 0;
|
||||
`
|
||||
),
|
||||
suffix: cx(
|
||||
@ -199,11 +205,16 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
|
||||
right: 0;
|
||||
`
|
||||
),
|
||||
loadingIndicator: css`
|
||||
& + * {
|
||||
margin-left: ${theme.spacing.xs};
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
export const Input: FC<Props> = props => {
|
||||
const { addonAfter, addonBefore, prefix, invalid, loading, size = 'auto', ...restProps } = props;
|
||||
export const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
const { addonAfter, addonBefore, prefix, suffix, invalid, loading, size = 'auto', ...restProps } = props;
|
||||
/**
|
||||
* Prefix & suffix are positioned absolutely within inputWrapper. We use client rects below to apply correct padding to the input
|
||||
* when prefix/suffix is larger than default (28px = 16px(icon) + 12px(left/right paddings)).
|
||||
@ -227,6 +238,7 @@ export const Input: FC<Props> = props => {
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
className={styles.input}
|
||||
{...restProps}
|
||||
style={{
|
||||
@ -235,9 +247,10 @@ export const Input: FC<Props> = props => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
{(suffix || loading) && (
|
||||
<div className={styles.suffix} ref={suffixRef}>
|
||||
<Icon name="spinner" className="fa-spin" />
|
||||
{loading && <Icon name="spinner" className={cx('fa-spin', styles.loadingIndicator)} />}
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -245,4 +258,4 @@ export const Input: FC<Props> = props => {
|
||||
{!!addonAfter && <div className={styles.addon}>{addonAfter}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button, ButtonVariant, ButtonProps } from '../Button';
|
||||
import { ButtonSize } from '../../Button/types';
|
||||
import { SelectCommonProps, SelectBase } from './SelectBase';
|
||||
import { css } from 'emotion';
|
||||
import { useTheme } from '../../../themes';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { IconType } from '../../Icon/types';
|
||||
|
||||
interface ButtonSelectProps<T> extends Omit<SelectCommonProps<T>, 'renderControl' | 'size' | 'prefix'> {
|
||||
icon?: IconType;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
interface SelectButtonProps extends Omit<ButtonProps, 'icon'> {
|
||||
icon?: IconType;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
const SelectButton: React.FC<SelectButtonProps> = ({ icon, children, isOpen, ...buttonProps }) => {
|
||||
const theme = useTheme();
|
||||
const styles = {
|
||||
wrapper: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 200px;
|
||||
text-overflow: ellipsis;
|
||||
`,
|
||||
iconWrap: css`
|
||||
padding: 0 15px 0 0;
|
||||
`,
|
||||
caretWrap: css`
|
||||
padding-left: ${theme.spacing.sm};
|
||||
margin-left: ${theme.spacing.sm};
|
||||
margin-right: -${theme.spacing.sm};
|
||||
height: 100%;
|
||||
`,
|
||||
};
|
||||
const buttonIcon = `fa fa-${icon}`;
|
||||
const caretIcon = isOpen ? 'caret-up' : 'caret-down';
|
||||
return (
|
||||
<Button {...buttonProps} icon={buttonIcon}>
|
||||
<span className={styles.wrapper}>
|
||||
<span>{children}</span>
|
||||
<span className={styles.caretWrap}>
|
||||
<Icon name={caretIcon} />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export function ButtonSelect<T>({
|
||||
placeholder,
|
||||
icon,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
disabled,
|
||||
...selectProps
|
||||
}: ButtonSelectProps<T>) {
|
||||
const buttonProps = {
|
||||
icon,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
disabled,
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectBase
|
||||
{...selectProps}
|
||||
renderControl={({ onBlur, onClick, value, isOpen }) => {
|
||||
return (
|
||||
<SelectButton {...buttonProps} onBlur={onBlur} onClick={onClick} isOpen={isOpen}>
|
||||
{value ? value.label : placeholder}
|
||||
</SelectButton>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
|
||||
interface DropdownIndicatorProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const DropdownIndicator: React.FC<DropdownIndicatorProps> = ({ isOpen }) => {
|
||||
const icon = isOpen ? 'caret-up' : 'caret-down';
|
||||
return <Icon name={icon} />;
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../../themes/ThemeContext';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
import { cx, css } from 'emotion';
|
||||
|
||||
export const IndicatorsContainer = React.forwardRef<HTMLDivElement, React.PropsWithChildren<any>>((props, ref) => {
|
||||
const { children } = props;
|
||||
const theme = useTheme();
|
||||
const styles = getInputStyles({ theme, invalid: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
styles.suffix,
|
||||
css`
|
||||
position: relative;
|
||||
top: auto;
|
||||
`
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../../themes/ThemeContext';
|
||||
import { getFocusCss, sharedInputStyle } from '../commonStyles';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
import { cx, css } from 'emotion';
|
||||
import { stylesFactory } from '../../../themes';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
interface InputControlProps {
|
||||
/** Show an icon as a prefix in the input */
|
||||
prefix?: JSX.Element | string | null;
|
||||
focused: boolean;
|
||||
invalid: boolean;
|
||||
disabled: boolean;
|
||||
innerProps: any;
|
||||
}
|
||||
|
||||
const getInputControlStyles = stylesFactory(
|
||||
(theme: GrafanaTheme, invalid: boolean, focused: boolean, disabled: boolean, withPrefix: boolean) => {
|
||||
const styles = getInputStyles({ theme, invalid });
|
||||
|
||||
return {
|
||||
wrapper: cx(
|
||||
styles.wrapper,
|
||||
sharedInputStyle(theme, invalid),
|
||||
focused &&
|
||||
css`
|
||||
${getFocusCss(theme)}
|
||||
`,
|
||||
disabled && styles.inputDisabled,
|
||||
css`
|
||||
min-height: 32px;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
padding-right: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
`,
|
||||
withPrefix &&
|
||||
css`
|
||||
padding-left: 0;
|
||||
`
|
||||
),
|
||||
prefix: cx(
|
||||
styles.prefix,
|
||||
css`
|
||||
position: relative;
|
||||
`
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const InputControl = React.forwardRef<HTMLDivElement, React.PropsWithChildren<InputControlProps>>(
|
||||
function InputControl({ focused, invalid, disabled, children, innerProps, prefix, ...otherProps }, ref) {
|
||||
const theme = useTheme();
|
||||
const styles = getInputControlStyles(theme, invalid, focused, disabled, !!prefix);
|
||||
return (
|
||||
<div className={styles.wrapper} {...innerProps} ref={ref}>
|
||||
{prefix && <div className={cx(styles.prefix)}>{prefix}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../../themes';
|
||||
import { getSelectStyles } from './getSelectStyles';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
|
||||
interface MultiValueContainerProps {
|
||||
innerProps: any;
|
||||
}
|
||||
|
||||
export const MultiValueContainer: React.FC<MultiValueContainerProps> = ({ innerProps, children }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSelectStyles(theme);
|
||||
|
||||
return (
|
||||
<div {...innerProps} className={styles.multiValueContainer}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type MultiValueRemoveProps = {
|
||||
innerProps: any;
|
||||
};
|
||||
|
||||
export const MultiValueRemove: React.FC<MultiValueRemoveProps> = ({ children, innerProps }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSelectStyles(theme);
|
||||
return (
|
||||
<div {...innerProps} className={styles.multiValueRemove}>
|
||||
<Icon name="times" />
|
||||
</div>
|
||||
);
|
||||
};
|
265
packages/grafana-ui/src/components/Forms/Select/Select.story.tsx
Normal file
265
packages/grafana-ui/src/components/Forms/Select/Select.story.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Select, AsyncSelect, MultiSelect, AsyncMultiSelect } from './Select';
|
||||
import { withCenteredStory, withHorizontallyCenteredStory } from '../../../utils/storybook/withCenteredStory';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { getAvailableIcons, IconType } from '../../Icon/types';
|
||||
import { select, boolean } from '@storybook/addon-knobs';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { Button } from '../Button';
|
||||
import { ButtonSelect } from './ButtonSelect';
|
||||
import { getIconKnob } from '../../../utils/storybook/knobs';
|
||||
import kebabCase from 'lodash/kebabCase';
|
||||
|
||||
export default {
|
||||
title: 'UI/Forms/Select',
|
||||
component: Select,
|
||||
decorators: [withCenteredStory, withHorizontallyCenteredStory],
|
||||
};
|
||||
|
||||
const generateOptions = () => {
|
||||
const values = [
|
||||
'Sharilyn Markowitz',
|
||||
'Naomi Striplin',
|
||||
'Beau Bevel',
|
||||
'Garrett Starkes',
|
||||
'Hildegarde Pedro',
|
||||
'Gudrun Seyler',
|
||||
'Eboni Raines',
|
||||
'Hye Felix',
|
||||
'Chau Brito',
|
||||
'Heidy Zook',
|
||||
'Karima Husain',
|
||||
'Virgil Mckinny',
|
||||
'Kaley Dodrill',
|
||||
'Sharan Ruf',
|
||||
'Edgar Loveland',
|
||||
'Judie Sanger',
|
||||
'Season Bundrick',
|
||||
'Ok Vicente',
|
||||
'Garry Spitz',
|
||||
'Han Harnish',
|
||||
];
|
||||
|
||||
return values.map<SelectableValue<string>>(name => ({
|
||||
value: kebabCase(name),
|
||||
label: name,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadAsyncOptions = () => {
|
||||
return new Promise<Array<SelectableValue<string>>>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve(generateOptions());
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const getKnobs = () => {
|
||||
const BEHAVIOUR_GROUP = 'Behaviour props';
|
||||
const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP);
|
||||
const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP);
|
||||
const loading = boolean('Loading', false, BEHAVIOUR_GROUP);
|
||||
const prefixSuffixOpts = {
|
||||
None: null,
|
||||
Text: '$',
|
||||
...getAvailableIcons().reduce<Record<string, string>>((prev, c) => {
|
||||
return {
|
||||
...prev,
|
||||
[`Icon: ${c}`]: `icon-${c}`,
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
const VISUAL_GROUP = 'Visual options';
|
||||
// ---
|
||||
const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP);
|
||||
|
||||
let prefixEl: any = prefix;
|
||||
if (prefix && prefix.match(/icon-/g)) {
|
||||
prefixEl = <Icon name={prefix.replace(/icon-/g, '') as IconType} />;
|
||||
}
|
||||
|
||||
return {
|
||||
disabled,
|
||||
invalid,
|
||||
loading,
|
||||
prefixEl,
|
||||
};
|
||||
};
|
||||
|
||||
const getDynamicProps = () => {
|
||||
const knobs = getKnobs();
|
||||
return {
|
||||
disabled: knobs.disabled,
|
||||
isLoading: knobs.loading,
|
||||
invalid: knobs.invalid,
|
||||
prefix: knobs.prefixEl,
|
||||
};
|
||||
};
|
||||
|
||||
export const basic = () => {
|
||||
const [value, setValue] = useState<SelectableValue<string>>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
options={generateOptions()}
|
||||
value={value}
|
||||
onChange={v => {
|
||||
setValue(v);
|
||||
}}
|
||||
size="md"
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses plain values instead of SelectableValue<T>
|
||||
*/
|
||||
export const basicSelectPlainValue = () => {
|
||||
const [value, setValue] = useState<string>();
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
options={generateOptions()}
|
||||
value={value}
|
||||
onChange={v => {
|
||||
setValue(v.value);
|
||||
}}
|
||||
size="md"
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses plain values instead of SelectableValue<T>
|
||||
*/
|
||||
export const multiPlainValue = () => {
|
||||
const [value, setValue] = useState<string[]>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
options={generateOptions()}
|
||||
value={value}
|
||||
onChange={v => {
|
||||
setValue(v.map((v: any) => v.value));
|
||||
}}
|
||||
size="md"
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const multiSelect = () => {
|
||||
const [value, setValue] = useState<Array<SelectableValue<string>>>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
options={generateOptions()}
|
||||
value={value}
|
||||
onChange={v => {
|
||||
setValue(v);
|
||||
}}
|
||||
size="md"
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const multiSelectAsync = () => {
|
||||
const [value, setValue] = useState<Array<SelectableValue<string>>>();
|
||||
|
||||
return (
|
||||
<AsyncMultiSelect
|
||||
loadOptions={loadAsyncOptions}
|
||||
defaultOptions
|
||||
value={value}
|
||||
onChange={v => {
|
||||
setValue(v);
|
||||
}}
|
||||
size="md"
|
||||
allowCustomValue
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export const buttonSelect = () => {
|
||||
const [value, setValue] = useState<SelectableValue<string>>();
|
||||
const icon = getIconKnob();
|
||||
return (
|
||||
<ButtonSelect
|
||||
placeholder="Select all the things..."
|
||||
value={value}
|
||||
options={generateOptions()}
|
||||
onChange={v => {
|
||||
setValue(v);
|
||||
}}
|
||||
size="md"
|
||||
allowCustomValue
|
||||
icon={icon}
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const basicSelectAsync = () => {
|
||||
const [value, setValue] = useState<SelectableValue<string>>();
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
loadOptions={loadAsyncOptions}
|
||||
defaultOptions
|
||||
value={value}
|
||||
onChange={v => {
|
||||
setValue(v);
|
||||
}}
|
||||
size="md"
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const customizedControl = () => {
|
||||
const [value, setValue] = useState<SelectableValue<string>>();
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={generateOptions()}
|
||||
value={value}
|
||||
onChange={v => {
|
||||
setValue(v);
|
||||
}}
|
||||
size="md"
|
||||
renderControl={({ isOpen, value, ...otherProps }) => {
|
||||
return <Button {...otherProps}> {isOpen ? 'Open' : 'Closed'}</Button>;
|
||||
}}
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const customValueCreation = () => {
|
||||
const [value, setValue] = useState<SelectableValue<string>>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
options={generateOptions()}
|
||||
value={value}
|
||||
onChange={v => {
|
||||
setValue(v);
|
||||
}}
|
||||
size="md"
|
||||
allowCustomValue
|
||||
{...getDynamicProps()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
31
packages/grafana-ui/src/components/Forms/Select/Select.tsx
Normal file
31
packages/grafana-ui/src/components/Forms/Select/Select.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { SelectCommonProps, SelectBase, MultiSelectCommonProps, SelectAsyncProps } from './SelectBase';
|
||||
|
||||
export function Select<T>(props: SelectCommonProps<T>) {
|
||||
return <SelectBase {...props} />;
|
||||
}
|
||||
|
||||
export function MultiSelect<T>(props: MultiSelectCommonProps<T>) {
|
||||
// @ts-ignore
|
||||
return <SelectBase {...props} isMulti />;
|
||||
}
|
||||
|
||||
interface AsyncSelectProps<T> extends Omit<SelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
|
||||
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
|
||||
value?: SelectableValue<T>;
|
||||
}
|
||||
|
||||
export function AsyncSelect<T>(props: AsyncSelectProps<T>) {
|
||||
return <SelectBase {...props} />;
|
||||
}
|
||||
|
||||
interface AsyncMultiSelectProps<T> extends Omit<MultiSelectCommonProps<T>, 'options'>, SelectAsyncProps<T> {
|
||||
// AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options
|
||||
value?: Array<SelectableValue<T>>;
|
||||
}
|
||||
|
||||
export function AsyncMultiSelect<T>(props: AsyncMultiSelectProps<T>) {
|
||||
// @ts-ignore
|
||||
return <SelectBase {...props} isMulti />;
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { SelectBase } from './SelectBase';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
const onChangeHandler = () => jest.fn();
|
||||
const findMenuElement = (container: ReactWrapper) => container.find({ 'aria-label': 'Select options menu' });
|
||||
const options: Array<SelectableValue<number>> = [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 2,
|
||||
},
|
||||
];
|
||||
|
||||
describe('SelectBase', () => {
|
||||
it('renders without error', () => {
|
||||
mount(<SelectBase onChange={onChangeHandler} />);
|
||||
});
|
||||
|
||||
it('renders empty options information', () => {
|
||||
const container = mount(<SelectBase onChange={onChangeHandler} isOpen />);
|
||||
const noopt = container.find({ 'aria-label': 'No options provided' });
|
||||
expect(noopt).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('when openMenuOnFocus prop', () => {
|
||||
describe('is provided', () => {
|
||||
it('opens on focus', () => {
|
||||
const container = mount(<SelectBase onChange={onChangeHandler} openMenuOnFocus />);
|
||||
container.find('input').simulate('focus');
|
||||
|
||||
const menu = findMenuElement(container);
|
||||
expect(menu).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe('is not provided', () => {
|
||||
it.each`
|
||||
key
|
||||
${'ArrowDown'}
|
||||
${'ArrowUp'}
|
||||
${' '}
|
||||
`('opens on arrow down/up or space', ({ key }) => {
|
||||
const container = mount(<SelectBase onChange={onChangeHandler} />);
|
||||
const input = container.find('input');
|
||||
input.simulate('focus');
|
||||
input.simulate('keydown', { key });
|
||||
const menu = findMenuElement(container);
|
||||
expect(menu).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('options', () => {
|
||||
it('renders menu with provided options', () => {
|
||||
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 options={options} onChange={handler} isOpen />);
|
||||
const menuOptions = container.find({ 'aria-label': 'Select option' });
|
||||
expect(menuOptions).toHaveLength(2);
|
||||
const menuOption = menuOptions.first();
|
||||
menuOption.simulate('click');
|
||||
|
||||
expect(spy).toBeCalledWith({
|
||||
label: 'Option 1',
|
||||
value: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
315
packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx
Normal file
315
packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import React from 'react';
|
||||
import { SelectableValue, deprecationWarning } from '@grafana/data';
|
||||
// @ts-ignore
|
||||
import { default as ReactSelect, Creatable } from '@torkelo/react-select';
|
||||
// @ts-ignore
|
||||
import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async';
|
||||
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { css } from 'emotion';
|
||||
import { inputSizes } from '../commonStyles';
|
||||
import { FormInputSize } from '../types';
|
||||
import resetSelectStyles from './resetSelectStyles';
|
||||
import { SelectMenu, SelectMenuOptions } from './SelectMenu';
|
||||
import { IndicatorsContainer } from './IndicatorsContainer';
|
||||
import { ValueContainer } from './ValueContainer';
|
||||
import { InputControl } from './InputControl';
|
||||
import { DropdownIndicator } from './DropdownIndicator';
|
||||
import { SelectOptionGroup } from './SelectOptionGroup';
|
||||
import { SingleValue } from './SingleValue';
|
||||
import { MultiValueContainer, MultiValueRemove } from './MultiValue';
|
||||
import { useTheme } from '../../../themes';
|
||||
import { getSelectStyles } from './getSelectStyles';
|
||||
|
||||
type SelectValue<T> = T | SelectableValue<T> | T[] | Array<SelectableValue<T>>;
|
||||
|
||||
export interface SelectCommonProps<T> {
|
||||
className?: string;
|
||||
options?: Array<SelectableValue<T>>;
|
||||
defaultValue?: any;
|
||||
value?: SelectValue<T>;
|
||||
getOptionLabel?: (item: SelectableValue<T>) => string;
|
||||
getOptionValue?: (item: SelectableValue<T>) => string;
|
||||
onChange: (value: SelectableValue<T>) => {} | void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isClearable?: boolean;
|
||||
autoFocus?: boolean;
|
||||
openMenuOnFocus?: boolean;
|
||||
onBlur?: () => void;
|
||||
maxMenuHeight?: number;
|
||||
isLoading?: boolean;
|
||||
noOptionsMessage?: string;
|
||||
isMulti?: boolean;
|
||||
backspaceRemovesValue?: boolean;
|
||||
isOpen?: boolean;
|
||||
components?: any;
|
||||
onOpenMenu?: () => void;
|
||||
onCloseMenu?: () => void;
|
||||
tabSelectsValue?: boolean;
|
||||
formatCreateLabel?: (input: string) => string;
|
||||
allowCustomValue?: boolean;
|
||||
width?: number;
|
||||
size?: FormInputSize;
|
||||
/** item to be rendered in front of the input */
|
||||
prefix?: JSX.Element | string | null;
|
||||
renderControl?: ControlComponent<T>;
|
||||
}
|
||||
|
||||
export interface SelectAsyncProps<T> {
|
||||
/** When specified as boolean the loadOptions will execute when component is mounted */
|
||||
defaultOptions?: boolean | Array<SelectableValue<T>>;
|
||||
/** Asynchroniously load select options */
|
||||
loadOptions?: (query: string) => Promise<Array<SelectableValue<T>>>;
|
||||
/** Message to display when options are loading */
|
||||
loadingMessage?: string;
|
||||
}
|
||||
|
||||
export interface MultiSelectCommonProps<T> extends Omit<SelectCommonProps<T>, 'onChange' | 'isMulti' | 'value'> {
|
||||
value?: Array<SelectableValue<T>> | T[];
|
||||
onChange: (item: Array<SelectableValue<T>>) => {} | void;
|
||||
}
|
||||
|
||||
export interface SelectBaseProps<T> extends SelectCommonProps<T>, SelectAsyncProps<T> {
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomControlProps<T> {
|
||||
ref: React.Ref<any>;
|
||||
isOpen: boolean;
|
||||
/** Currently selected value */
|
||||
value?: SelectableValue<T>;
|
||||
/** onClick will be automatically passed to custom control allowing menu toggle */
|
||||
onClick: () => void;
|
||||
/** onBlur will be automatically passed to custom control closing the menu on element blur */
|
||||
onBlur: () => void;
|
||||
disabled: boolean;
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
export type ControlComponent<T> = React.ComponentType<CustomControlProps<T>>;
|
||||
|
||||
const CustomControl = (props: any) => {
|
||||
const {
|
||||
children,
|
||||
innerProps,
|
||||
selectProps: { menuIsOpen, onMenuClose, onMenuOpen },
|
||||
isFocused,
|
||||
isMulti,
|
||||
getValue,
|
||||
innerRef,
|
||||
} = props;
|
||||
const selectProps = props.selectProps as SelectBaseProps<any>;
|
||||
|
||||
if (selectProps.renderControl) {
|
||||
return React.createElement(selectProps.renderControl, {
|
||||
isOpen: menuIsOpen,
|
||||
value: isMulti ? getValue() : getValue()[0],
|
||||
ref: innerRef,
|
||||
onClick: menuIsOpen ? onMenuClose : onMenuOpen,
|
||||
onBlur: onMenuClose,
|
||||
disabled: !!selectProps.disabled,
|
||||
invalid: !!selectProps.invalid,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<InputControl
|
||||
ref={innerRef}
|
||||
innerProps={innerProps}
|
||||
prefix={selectProps.prefix}
|
||||
focused={isFocused}
|
||||
invalid={!!selectProps.invalid}
|
||||
disabled={!!selectProps.disabled}
|
||||
>
|
||||
{children}
|
||||
</InputControl>
|
||||
);
|
||||
};
|
||||
|
||||
export function SelectBase<T>({
|
||||
value,
|
||||
defaultValue,
|
||||
options = [],
|
||||
onChange,
|
||||
onBlur,
|
||||
onCloseMenu,
|
||||
onOpenMenu,
|
||||
placeholder = 'Choose',
|
||||
getOptionValue,
|
||||
getOptionLabel,
|
||||
isSearchable = true,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isMulti = false,
|
||||
isLoading = false,
|
||||
isOpen,
|
||||
autoFocus = false,
|
||||
openMenuOnFocus = false,
|
||||
maxMenuHeight = 300,
|
||||
noOptionsMessage = 'No options found',
|
||||
tabSelectsValue = true,
|
||||
backspaceRemovesValue = true,
|
||||
allowCustomValue = false,
|
||||
size = 'auto',
|
||||
prefix,
|
||||
formatCreateLabel,
|
||||
loadOptions,
|
||||
loadingMessage = 'Loading options...',
|
||||
defaultOptions,
|
||||
renderControl,
|
||||
width,
|
||||
invalid,
|
||||
components,
|
||||
}: SelectBaseProps<T>) {
|
||||
const theme = useTheme();
|
||||
const styles = getSelectStyles(theme);
|
||||
let Component: ReactSelect | Creatable = ReactSelect;
|
||||
const creatableProps: any = {};
|
||||
let asyncSelectProps: any = {};
|
||||
|
||||
let selectedValue = [];
|
||||
if (isMulti && loadOptions) {
|
||||
selectedValue = value as any;
|
||||
} else {
|
||||
// If option is passed as a plain value (value property from SelectableValue property)
|
||||
// we are selecting the corresponding value from the options
|
||||
if (isMulti && value && Array.isArray(value) && !loadOptions) {
|
||||
// @ts-ignore
|
||||
selectedValue = value.map(v => {
|
||||
return options.filter(o => {
|
||||
return v === o.value || o.value === v.value;
|
||||
})[0];
|
||||
});
|
||||
} else {
|
||||
selectedValue = options.filter(o => o.value === value || o === value);
|
||||
}
|
||||
}
|
||||
|
||||
const commonSelectProps = {
|
||||
autoFocus,
|
||||
placeholder,
|
||||
isSearchable,
|
||||
// Passing isDisabled as react-select accepts this prop
|
||||
isDisabled: disabled,
|
||||
// Also passing disabled, as this is the new Select API, and I want to use this prop instead of react-select's one
|
||||
disabled,
|
||||
invalid,
|
||||
prefix,
|
||||
isClearable,
|
||||
isLoading,
|
||||
menuIsOpen: isOpen,
|
||||
defaultValue,
|
||||
value: isMulti ? selectedValue : selectedValue[0],
|
||||
getOptionLabel,
|
||||
getOptionValue,
|
||||
openMenuOnFocus,
|
||||
maxMenuHeight,
|
||||
isMulti,
|
||||
backspaceRemovesValue,
|
||||
onMenuOpen: onOpenMenu,
|
||||
onMenuClose: onCloseMenu,
|
||||
tabSelectsValue,
|
||||
options,
|
||||
onChange,
|
||||
onBlur,
|
||||
menuShouldScrollIntoView: false,
|
||||
renderControl,
|
||||
};
|
||||
|
||||
// width property is deprecated in favor of size or className
|
||||
let widthClass = '';
|
||||
if (width) {
|
||||
deprecationWarning('Select', 'width property', 'size or className');
|
||||
widthClass = 'width-' + width;
|
||||
}
|
||||
|
||||
if (allowCustomValue) {
|
||||
Component = Creatable;
|
||||
creatableProps.formatCreateLabel = formatCreateLabel ?? ((input: string) => `Create: ${input}`);
|
||||
}
|
||||
|
||||
// Instead of having AsyncSelect, as a separate component we render ReactAsyncSelect
|
||||
if (loadOptions) {
|
||||
Component = ReactAsyncSelect;
|
||||
asyncSelectProps = {
|
||||
loadOptions,
|
||||
defaultOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
components={{
|
||||
MenuList: SelectMenu,
|
||||
Group: SelectOptionGroup,
|
||||
ValueContainer: ValueContainer,
|
||||
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(),
|
||||
singleValue: () => {
|
||||
return css`
|
||||
overflow: hidden;
|
||||
`;
|
||||
},
|
||||
container: () => {
|
||||
return css`
|
||||
position: relative;
|
||||
${inputSizes()[size]}
|
||||
`;
|
||||
},
|
||||
placeholder: () => {
|
||||
return css`
|
||||
display: inline-block;
|
||||
color: hsl(0, 0%, 50%);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
box-sizing: border-box;
|
||||
line-height: 1;
|
||||
`;
|
||||
},
|
||||
}}
|
||||
className={widthClass}
|
||||
{...commonSelectProps}
|
||||
{...creatableProps}
|
||||
{...asyncSelectProps}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../../themes/ThemeContext';
|
||||
import { getSelectStyles } from './getSelectStyles';
|
||||
import { cx } from 'emotion';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { Icon } from '../../Icon/Icon';
|
||||
import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
|
||||
interface SelectMenuProps {
|
||||
maxHeight: number;
|
||||
innerRef: React.Ref<any>;
|
||||
}
|
||||
|
||||
export const SelectMenu = React.forwardRef<HTMLDivElement, React.PropsWithChildren<SelectMenuProps>>((props, ref) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSelectStyles(theme);
|
||||
const { children, maxHeight, innerRef } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.menu} ref={innerRef} style={{ maxHeight }} aria-label="Select options menu">
|
||||
<CustomScrollbar autoHide={false} autoHeightMax="inherit" hideHorizontalTrack>
|
||||
{children}
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SelectMenu.displayName = 'SelectMenu';
|
||||
|
||||
interface SelectMenuOptionProps<T> {
|
||||
isDisabled: boolean;
|
||||
isFocused: boolean;
|
||||
isSelected: boolean;
|
||||
innerProps: any;
|
||||
renderOptionLabel?: (value: SelectableValue<T>) => JSX.Element;
|
||||
data: SelectableValue<T>;
|
||||
}
|
||||
|
||||
export const SelectMenuOptions = React.forwardRef<HTMLDivElement, React.PropsWithChildren<SelectMenuOptionProps<any>>>(
|
||||
(props, ref) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSelectStyles(theme);
|
||||
const { children, innerProps, data, renderOptionLabel, isSelected, isFocused } = props;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cx(styles.option, isFocused && styles.optionFocused)}
|
||||
{...innerProps}
|
||||
aria-label="Select option"
|
||||
>
|
||||
<span>{renderOptionLabel ? renderOptionLabel(data) : children}</span>
|
||||
{isSelected && (
|
||||
<span>
|
||||
<Icon name="check" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
@ -0,0 +1,95 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { GroupProps } from 'react-select/lib/components/Group';
|
||||
import { stylesFactory, withTheme, selectThemeVariant } from '../../../themes';
|
||||
import { Themeable } from '../../../types';
|
||||
|
||||
interface ExtendedGroupProps extends GroupProps<any>, Themeable {
|
||||
data: {
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
options: any[];
|
||||
};
|
||||
}
|
||||
|
||||
interface State {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
const getSelectOptionGroupStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const optionBorder = selectThemeVariant(
|
||||
{
|
||||
light: theme.colors.gray4,
|
||||
dark: theme.colors.dark9,
|
||||
},
|
||||
theme.type
|
||||
);
|
||||
return {
|
||||
header: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-items: center;
|
||||
cursor: pointer;
|
||||
padding: 7px 10px;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid ${optionBorder};
|
||||
text-transform: capitalize;
|
||||
`,
|
||||
label: css`
|
||||
flex-grow: 1;
|
||||
`,
|
||||
icon: css`
|
||||
padding-right: 2px;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
class UnthemedSelectOptionGroup extends PureComponent<ExtendedGroupProps, State> {
|
||||
state = {
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.data.expanded) {
|
||||
this.setState({ expanded: true });
|
||||
} else if (this.props.selectProps && this.props.selectProps.value) {
|
||||
const { value } = this.props.selectProps.value;
|
||||
|
||||
if (value && this.props.options.some(option => option.value === value)) {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(nextProps: ExtendedGroupProps) {
|
||||
if (nextProps.selectProps.inputValue !== '') {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
}
|
||||
|
||||
onToggleChildren = () => {
|
||||
this.setState(prevState => ({
|
||||
expanded: !prevState.expanded,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, label, theme } = this.props;
|
||||
const { expanded } = this.state;
|
||||
const styles = getSelectOptionGroupStyles(theme);
|
||||
const icon = expanded ? 'fa-caret-left' : 'fa-caret-down';
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.header} onClick={this.onToggleChildren}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<i className={cx('fa', icon, styles.icon)} />{' '}
|
||||
</div>
|
||||
{expanded && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const SelectOptionGroup = withTheme(UnthemedSelectOptionGroup);
|
@ -1,16 +1,27 @@
|
||||
import React from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { css } from 'emotion';
|
||||
|
||||
// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
|
||||
// @ts-ignore
|
||||
import { components } from '@torkelo/react-select';
|
||||
import { useDelayedSwitch } from '../../utils/useDelayedSwitch';
|
||||
import { stylesFactory } from '../../themes';
|
||||
import { SlideOutTransition } from '../transitions/SlideOutTransition';
|
||||
import { FadeTransition } from '../transitions/FadeTransition';
|
||||
import { Spinner } from '../Spinner/Spinner';
|
||||
import { useDelayedSwitch } from '../../../utils/useDelayedSwitch';
|
||||
import { stylesFactory, useTheme } from '../../../themes';
|
||||
import { SlideOutTransition } from '../../transitions/SlideOutTransition';
|
||||
import { FadeTransition } from '../../transitions/FadeTransition';
|
||||
import { Spinner } from '../../Spinner/Spinner';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
const getStyles = stylesFactory(() => {
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const singleValue = css`
|
||||
label: singleValue;
|
||||
color: ${theme.colors.formInputText};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
/* padding-right: 40px; */
|
||||
`;
|
||||
const container = css`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@ -27,7 +38,7 @@ const getStyles = stylesFactory(() => {
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
return { container, item };
|
||||
return { singleValue, container, item };
|
||||
});
|
||||
|
||||
type Props = {
|
||||
@ -41,13 +52,14 @@ type Props = {
|
||||
|
||||
export const SingleValue = (props: Props) => {
|
||||
const { children, data } = props;
|
||||
const styles = getStyles();
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
const loading = useDelayedSwitch(data.loading || false, { delay: 250, duration: 750 });
|
||||
|
||||
return (
|
||||
<components.SingleValue {...props}>
|
||||
<div className={cx('gf-form-select-box__img-value')}>
|
||||
<div className={styles.singleValue}>
|
||||
{data.imgUrl ? (
|
||||
<FadeWithImage loading={loading} imgUrl={data.imgUrl} />
|
||||
) : (
|
||||
@ -64,7 +76,8 @@ export const SingleValue = (props: Props) => {
|
||||
};
|
||||
|
||||
const FadeWithImage = (props: { loading: boolean; imgUrl: string }) => {
|
||||
const styles = getStyles();
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { cx } from 'emotion';
|
||||
import { useTheme } from '../../../themes/ThemeContext';
|
||||
import { getSelectStyles } from './getSelectStyles';
|
||||
|
||||
export const ValueContainer = (props: any) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSelectStyles(theme);
|
||||
const { children, isMulti } = props;
|
||||
return <div className={cx(styles.valueContainer, isMulti && styles.valueContainerMulti)}>{children}</div>;
|
||||
};
|
@ -0,0 +1,81 @@
|
||||
import { stylesFactory } from '../../../themes/stylesFactory';
|
||||
import { selectThemeVariant as stv } from '../../../themes/selectThemeVariant';
|
||||
import { css } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
export const getSelectStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
const bgColor = stv({ light: theme.colors.white, dark: theme.colors.gray15 }, theme.type);
|
||||
const menuShadowColor = stv({ light: theme.colors.gray4, dark: theme.colors.black }, theme.type);
|
||||
const optionBgHover = stv({ light: theme.colors.gray7, dark: theme.colors.gray10 }, theme.type);
|
||||
const multiValueContainerBg = stv({ light: theme.colors.gray6, dark: theme.colors.gray05 }, theme.type);
|
||||
const multiValueColor = stv({ light: theme.colors.gray25, dark: theme.colors.gray85 }, theme.type);
|
||||
|
||||
return {
|
||||
menu: css`
|
||||
background: ${bgColor};
|
||||
box-shadow: 0px 4px 4px ${menuShadowColor};
|
||||
position: absolute;
|
||||
min-width: 100%;
|
||||
`,
|
||||
option: css`
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: ${optionBgHover};
|
||||
}
|
||||
`,
|
||||
optionFocused: css`
|
||||
background: ${optionBgHover};
|
||||
border-image: linear-gradient(#f05a28 30%, #fbca0a 99%);
|
||||
border-image-slice: 1;
|
||||
border-style: solid;
|
||||
border-top: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left-width: 2px;
|
||||
`,
|
||||
singleValue: css`
|
||||
color: ${theme.colors.formInputText};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
`,
|
||||
valueContainer: css`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0%;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
`,
|
||||
valueContainerMulti: css`
|
||||
flex-wrap: wrap;
|
||||
`,
|
||||
loadingMessage: css`
|
||||
padding: ${theme.spacing.sm};
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
`,
|
||||
multiValueContainer: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
background: ${multiValueContainerBg};
|
||||
border-radius: ${theme.border.radius.sm};
|
||||
padding: ${theme.spacing.xs} ${theme.spacing.xxs} ${theme.spacing.xs} ${theme.spacing.sm};
|
||||
margin: ${theme.spacing.xxs} ${theme.spacing.xs} ${theme.spacing.xxs} 0;
|
||||
color: ${multiValueColor};
|
||||
`,
|
||||
multiValueRemove: css`
|
||||
margin-left: ${theme.spacing.xs};
|
||||
`,
|
||||
};
|
||||
});
|
@ -25,9 +25,10 @@ export const sharedInputStyle = (theme: GrafanaTheme, invalid = false) => {
|
||||
font-size: ${theme.typography.size.md};
|
||||
color: ${colors.formInputText};
|
||||
border: 1px solid ${borderColor};
|
||||
padding: 0 ${theme.spacing.sm} 0 ${theme.spacing.sm};
|
||||
|
||||
&:hover {
|
||||
border-color: ${colors.formInputBorder};
|
||||
border-color: ${borderColor};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@ -53,7 +54,7 @@ export const inputSizes = () => {
|
||||
width: 580px;
|
||||
`,
|
||||
auto: css`
|
||||
width: 100%;
|
||||
width: auto;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { getFormStyles } from './getFormStyles';
|
||||
import { Label } from './Label';
|
||||
import { Input } from './Input/Input';
|
||||
import { Select } from './Select/Select';
|
||||
import { Form } from './Form';
|
||||
import { Field } from './Field';
|
||||
import { Button } from './Button';
|
||||
@ -12,6 +13,7 @@ const Forms = {
|
||||
Form,
|
||||
Field,
|
||||
Button,
|
||||
Select,
|
||||
};
|
||||
|
||||
export default Forms;
|
||||
|
@ -6,6 +6,8 @@ import { IconType } from './types';
|
||||
export interface IconProps {
|
||||
name: IconType;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onMouseDown?: React.MouseEventHandler;
|
||||
}
|
||||
|
||||
const getIconStyles = stylesFactory(() => {
|
||||
@ -23,9 +25,9 @@ const getIconStyles = stylesFactory(() => {
|
||||
};
|
||||
});
|
||||
|
||||
export const Icon: React.FC<IconProps> = ({ name, className }) => {
|
||||
export const Icon: React.FC<IconProps> = ({ name, className, onClick, onMouseDown }) => {
|
||||
const styles = getIconStyles();
|
||||
return <i className={cx(styles.icon, 'fa', `fa-${name}`, className)} />;
|
||||
return <i className={cx(styles.icon, 'fa', `fa-${name}`, className)} onClick={onClick} onMouseDown={onMouseDown} />;
|
||||
};
|
||||
|
||||
Icon.displayName = 'Icon';
|
||||
|
@ -24,7 +24,7 @@ SelectStories.add('default', () => {
|
||||
{(value, updateValue) => {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
placeholder="Choose..."
|
||||
options={options}
|
||||
onChange={value => {
|
||||
action('onChanged fired')(value);
|
||||
@ -39,7 +39,8 @@ SelectStories.add('default', () => {
|
||||
|
||||
SelectStories.add('With allowCustomValue', () => {
|
||||
const intialState: SelectableValue<string> = { label: 'A label', value: 'A value' };
|
||||
const value = object<SelectableValue<string>>('Selected Value:', intialState);
|
||||
// @ts-ignore
|
||||
const value = object<SelectableValue<string>>('Selected Value:', null);
|
||||
const options = object<Array<SelectableValue<string>>>('Options:', [
|
||||
intialState,
|
||||
{ label: 'Another label', value: 'Another value' },
|
||||
@ -50,7 +51,8 @@ SelectStories.add('With allowCustomValue', () => {
|
||||
{(value, updateValue) => {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
// value={value}
|
||||
placeholder="Choose..."
|
||||
options={options}
|
||||
allowCustomValue={true}
|
||||
onChange={value => {
|
||||
|
@ -14,54 +14,37 @@ import { components } from '@torkelo/react-select';
|
||||
|
||||
// Components
|
||||
import { SelectOption } from './SelectOption';
|
||||
import { SingleValue } from './SingleValue';
|
||||
import SelectOptionGroup from './SelectOptionGroup';
|
||||
import { SelectOptionGroup } from '../Forms/Select/SelectOptionGroup';
|
||||
import { SingleValue } from '../Forms/Select/SingleValue';
|
||||
import { SelectCommonProps, SelectAsyncProps } from '../Forms/Select/SelectBase';
|
||||
import IndicatorsContainer from './IndicatorsContainer';
|
||||
import NoOptionsMessage from './NoOptionsMessage';
|
||||
import resetSelectStyles from './resetSelectStyles';
|
||||
import resetSelectStyles from '../Forms/Select/resetSelectStyles';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
import { PopoverContent } from '../Tooltip/Tooltip';
|
||||
import { Tooltip } from '../Tooltip/Tooltip';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
export interface CommonProps<T> {
|
||||
defaultValue?: any;
|
||||
getOptionLabel?: (item: SelectableValue<T>) => string;
|
||||
getOptionValue?: (item: SelectableValue<T>) => string;
|
||||
onChange: (item: SelectableValue<T>) => {} | void;
|
||||
placeholder?: string;
|
||||
width?: number;
|
||||
value?: SelectableValue<T>;
|
||||
className?: string;
|
||||
isDisabled?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isClearable?: boolean;
|
||||
autoFocus?: boolean;
|
||||
openMenuOnFocus?: boolean;
|
||||
onBlur?: () => void;
|
||||
maxMenuHeight?: number;
|
||||
isLoading?: boolean;
|
||||
noOptionsMessage?: () => string;
|
||||
isMulti?: boolean;
|
||||
backspaceRemovesValue?: boolean;
|
||||
isOpen?: boolean;
|
||||
components?: any;
|
||||
tooltipContent?: PopoverContent;
|
||||
onOpenMenu?: () => void;
|
||||
onCloseMenu?: () => void;
|
||||
tabSelectsValue?: boolean;
|
||||
formatCreateLabel?: (input: string) => string;
|
||||
allowCustomValue: boolean;
|
||||
}
|
||||
/**
|
||||
* Changes in new selects:
|
||||
* - noOptionsMessage & loadingMessage is of string type
|
||||
* - isDisabled is renamed to disabled
|
||||
*/
|
||||
type LegacyCommonProps<T> = Omit<SelectCommonProps<T>, 'noOptionsMessage' | 'disabled' | 'value'>;
|
||||
|
||||
export interface SelectProps<T> extends CommonProps<T> {
|
||||
options: Array<SelectableValue<T>>;
|
||||
}
|
||||
|
||||
interface AsyncProps<T> extends CommonProps<T> {
|
||||
defaultOptions: boolean;
|
||||
loadOptions: (query: string) => Promise<Array<SelectableValue<T>>>;
|
||||
interface AsyncProps<T> extends LegacyCommonProps<T>, Omit<SelectAsyncProps<T>, 'loadingMessage'> {
|
||||
loadingMessage?: () => string;
|
||||
noOptionsMessage?: () => string;
|
||||
tooltipContent?: PopoverContent;
|
||||
isDisabled?: boolean;
|
||||
value?: SelectableValue<T>;
|
||||
}
|
||||
|
||||
interface LegacySelectProps<T> extends LegacyCommonProps<T> {
|
||||
tooltipContent?: PopoverContent;
|
||||
noOptionsMessage?: () => string;
|
||||
isDisabled?: boolean;
|
||||
value?: SelectableValue<T>;
|
||||
}
|
||||
|
||||
export const MenuList = (props: any) => {
|
||||
@ -73,9 +56,8 @@ export const MenuList = (props: any) => {
|
||||
</components.MenuList>
|
||||
);
|
||||
};
|
||||
|
||||
export class Select<T> extends PureComponent<SelectProps<T>> {
|
||||
static defaultProps: Partial<SelectProps<any>> = {
|
||||
export class Select<T> extends PureComponent<LegacySelectProps<T>> {
|
||||
static defaultProps: Partial<LegacySelectProps<any>> = {
|
||||
className: '',
|
||||
isDisabled: false,
|
||||
isSearchable: true,
|
||||
@ -144,7 +126,6 @@ export class Select<T> extends PureComponent<SelectProps<T>> {
|
||||
|
||||
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
|
||||
const selectComponents = { ...Select.defaultProps.components, ...components };
|
||||
|
||||
return (
|
||||
<WrapInTooltip onCloseMenu={onCloseMenu} onOpenMenu={onOpenMenu} tooltipContent={tooltipContent} isOpen={isOpen}>
|
||||
{(onOpenMenuInternal, onCloseMenuInternal) => {
|
||||
@ -170,7 +151,7 @@ export class Select<T> extends PureComponent<SelectProps<T>> {
|
||||
onBlur={onBlur}
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
maxMenuHeight={maxMenuHeight}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
noOptionsMessage={() => noOptionsMessage}
|
||||
isMulti={isMulti}
|
||||
backspaceRemovesValue={backspaceRemovesValue}
|
||||
menuIsOpen={isOpen}
|
||||
@ -262,7 +243,7 @@ export class AsyncSelect<T> extends PureComponent<AsyncProps<T>> {
|
||||
defaultOptions={defaultOptions}
|
||||
placeholder={placeholder || 'Choose'}
|
||||
styles={resetSelectStyles()}
|
||||
loadingMessage={loadingMessage}
|
||||
loadingMessage={() => loadingMessage}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
isDisabled={isDisabled}
|
||||
isSearchable={isSearchable}
|
||||
|
@ -1,59 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { GroupProps } from 'react-select/lib/components/Group';
|
||||
|
||||
interface ExtendedGroupProps extends GroupProps<any> {
|
||||
data: {
|
||||
label: string;
|
||||
expanded: boolean;
|
||||
options: any[];
|
||||
};
|
||||
}
|
||||
|
||||
interface State {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export default class SelectOptionGroup extends PureComponent<ExtendedGroupProps, State> {
|
||||
state = {
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.data.expanded) {
|
||||
this.setState({ expanded: true });
|
||||
} else if (this.props.selectProps && this.props.selectProps.value) {
|
||||
const { value } = this.props.selectProps.value;
|
||||
|
||||
if (value && this.props.options.some(option => option.value === value)) {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(nextProps: ExtendedGroupProps) {
|
||||
if (nextProps.selectProps.inputValue !== '') {
|
||||
this.setState({ expanded: true });
|
||||
}
|
||||
}
|
||||
|
||||
onToggleChildren = () => {
|
||||
this.setState(prevState => ({
|
||||
expanded: !prevState.expanded,
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, label } = this.props;
|
||||
const { expanded } = this.state;
|
||||
|
||||
return (
|
||||
<div className="gf-form-select-box__option-group">
|
||||
<div className="gf-form-select-box__option-group__header" onClick={this.onToggleChildren}>
|
||||
<span className="flex-grow-1">{label}</span>
|
||||
<i className={`fa ${expanded ? 'fa-caret-left' : 'fa-caret-down'}`} />{' '}
|
||||
</div>
|
||||
{expanded && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@ export * from './Button/Button';
|
||||
export { Select, AsyncSelect } from './Select/Select';
|
||||
export { IndicatorsContainer } from './Select/IndicatorsContainer';
|
||||
export { NoOptionsMessage } from './Select/NoOptionsMessage';
|
||||
export { default as resetSelectStyles } from './Select/resetSelectStyles';
|
||||
export { default as resetSelectStyles } from './Forms/Select/resetSelectStyles';
|
||||
export { ButtonSelect } from './Select/ButtonSelect';
|
||||
export { Cascader, CascaderOption } from './Cascader/Cascader';
|
||||
|
||||
|
16
packages/grafana-ui/src/utils/storybook/knobs.ts
Normal file
16
packages/grafana-ui/src/utils/storybook/knobs.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { select } from '@storybook/addon-knobs';
|
||||
import { getAvailableIcons } from '../../components/Icon/types';
|
||||
|
||||
const VISUAL_GROUP = 'Visual options';
|
||||
|
||||
const iconOptions = {
|
||||
None: undefined,
|
||||
...getAvailableIcons().reduce<Record<string, string>>((prev, c) => {
|
||||
return {
|
||||
...prev,
|
||||
[`Icon: ${c}`]: `${c}`,
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
|
||||
export const getIconKnob = () => select('Icon', iconOptions, undefined, VISUAL_GROUP);
|
@ -361,7 +361,7 @@ Array [
|
||||
className="css-0 gf-form-select-box__single-value"
|
||||
>
|
||||
<div
|
||||
className="gf-form-select-box__img-value"
|
||||
className="css-38iae9-singleValue"
|
||||
>
|
||||
stackdriver auto
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user