Forms: revamped select (#21092)

This commit is contained in:
Dominik Prokop 2020-01-07 09:20:06 +01:00 committed by GitHub
parent eac66813ca
commit 630f75d3ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1357 additions and 197 deletions

View File

@ -29,6 +29,7 @@
"@grafana/slate-react": "0.22.9-grafana", "@grafana/slate-react": "0.22.9-grafana",
"@torkelo/react-select": "2.1.1", "@torkelo/react-select": "2.1.1",
"@types/react-color": "2.17.0", "@types/react-color": "2.17.0",
"@types/react-select": "2.0.15",
"@types/slate": "0.47.1", "@types/slate": "0.47.1",
"@types/slate-react": "0.22.5", "@types/slate-react": "0.22.5",
"bizcharts": "^3.5.5", "bizcharts": "^3.5.5",

View File

@ -4,13 +4,14 @@ import { Button, LinkButton } from './Button';
import withPropsCombinations from 'react-storybook-addon-props-combinations'; import withPropsCombinations from 'react-storybook-addon-props-combinations';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { ThemeableCombinationsRowRenderer } from '../../utils/storybook/CombinationsRowRenderer'; 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 ButtonStories = storiesOf('UI/Button', module);
const defaultProps = { const defaultProps = {
onClick: [action('Button clicked')], onClick: [action('Button clicked')],
children: ['Click, click!'], children: ['Click click!'],
}; };
const variants = { const variants = {
@ -35,15 +36,10 @@ ButtonStories.add('as button element', () => renderButtonStory(Button));
ButtonStories.add('as link element', () => renderButtonStory(LinkButton)); ButtonStories.add('as link element', () => renderButtonStory(LinkButton));
ButtonStories.add('with icon', () => { ButtonStories.add('with icon', () => {
const iconKnob = select( const icon = getIconKnob();
'Icon', return withPropsCombinations(
{ Button,
Plus: 'fa fa-plus', { ...variants, ...defaultProps, icon: [icon && `fa fa-${icon}`] },
User: 'fa fa-user', combinationOptions
Gear: 'fa fa-gear', )();
Annotation: 'gicon gicon-annotation',
},
'fa fa-plus'
);
return withPropsCombinations(Button, { ...variants, ...defaultProps, icon: [iconKnob] }, combinationOptions)();
}); });

View File

@ -18,12 +18,13 @@ type CommonProps = {
}; };
type ButtonProps = CommonProps & ButtonHTMLAttributes<HTMLButtonElement>; 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 theme = useContext(ThemeContext);
const { size, variant, icon, children, className, styles: stylesProp, ...buttonProps } = props; 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. // Default this to 'button', otherwise html defaults to 'submit' which then submits any form it is in.
buttonProps.type = buttonProps.type || 'button'; buttonProps.type = buttonProps.type || 'button';
const styles: ButtonStyles = const styles: ButtonStyles =
stylesProp || stylesProp ||
getButtonStyles({ getButtonStyles({
@ -32,14 +33,14 @@ export const Button: React.FunctionComponent<ButtonProps> = props => {
variant: variant || 'primary', variant: variant || 'primary',
}); });
const buttonClassName = cx(styles.button, icon && styles.buttonWithIcon, icon && !children && styles.iconButton);
return ( return (
<button className={cx(styles.button, className)} {...buttonProps}> <button className={buttonClassName} {...buttonProps} ref={ref}>
<ButtonContent iconClassName={styles.icon} className={styles.iconWrap} icon={icon}> <ButtonContent icon={icon}>{children}</ButtonContent>
{children}
</ButtonContent>
</button> </button>
); );
}; });
Button.displayName = 'Button'; Button.displayName = 'Button';
type LinkButtonProps = CommonProps & type LinkButtonProps = CommonProps &
@ -48,7 +49,8 @@ type LinkButtonProps = CommonProps &
// disabled. // disabled.
disabled?: boolean; disabled?: boolean;
}; };
export const LinkButton: React.FunctionComponent<LinkButtonProps> = props => {
export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>((props, ref) => {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
const { size, variant, icon, children, className, styles: stylesProp, ...anchorProps } = props; const { size, variant, icon, children, className, styles: stylesProp, ...anchorProps } = props;
const styles: ButtonStyles = const styles: ButtonStyles =
@ -59,12 +61,11 @@ export const LinkButton: React.FunctionComponent<LinkButtonProps> = props => {
variant: variant || 'primary', variant: variant || 'primary',
}); });
const buttonClassName = cx(styles.button, icon && styles.buttonWithIcon, icon && !children && styles.iconButton);
return ( return (
<a className={cx(styles.button, className)} {...anchorProps}> <a className={buttonClassName} {...anchorProps} ref={ref}>
<ButtonContent iconClassName={styles.icon} className={styles.iconWrap} icon={icon}> <ButtonContent icon={icon}>{children}</ButtonContent>
{children}
</ButtonContent>
</a> </a>
); );
}; });
LinkButton.displayName = 'LinkButton'; LinkButton.displayName = 'LinkButton';

View File

@ -1,39 +1,44 @@
import React from 'react'; import React from 'react';
import cx from 'classnames';
import { css } from 'emotion'; 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` content: css`
display: flex;
flex-direction: row;
align-items: center;
white-space: nowrap; white-space: nowrap;
height: 100%;
`,
icon: css`
& + * {
margin-left: ${theme.spacing.sm};
}
`, `,
})); }));
type Props = { type Props = {
icon?: string; icon?: string;
className?: string; className?: string;
iconClassName?: string;
children: React.ReactNode; children: React.ReactNode;
}; };
export function ButtonContent(props: Props) { export function ButtonContent(props: Props) {
const { icon, className, iconClassName, children } = props; const { icon, children } = props;
const styles = getStyles(); const theme = useTheme();
if (icon && children) { const styles = getStyles(theme);
return (
<span className={cx(styles.content, className)}>
<i className={cx([icon, iconClassName])} />
&nbsp; &nbsp;
<span>{children}</span>
</span>
);
}
if (icon) {
return (
<span className={cx(styles.content, className)}>
<i className={cx([icon, iconClassName])} />
</span>
);
}
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>
);
} }

View File

@ -1,5 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // 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>&nbsp; &nbsp;<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>&nbsp; &nbsp;<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>"`;

View File

@ -120,10 +120,18 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StyleDep
box-shadow: none; box-shadow: none;
} }
`, `,
buttonWithIcon: css`
padding-left: ${theme.spacing.sm};
`,
// used for buttons with icon onlys
iconButton: css`
padding-right: 0;
`,
iconWrap: css` iconWrap: css`
label: button-icon-wrap; label: button-icon-wrap;
display: flex; & + * {
align-items: center; margin-left: ${theme.spacing.sm};
}
`, `,
}; };
}); });

View File

@ -12,6 +12,8 @@ export interface StyleDeps {
export interface ButtonStyles { export interface ButtonStyles {
button: string; button: string;
buttonWithIcon: string;
iconButton: string;
iconWrap: string; iconWrap: string;
icon?: string; icon?: string;
} }

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { select, text } from '@storybook/addon-knobs';
import { Button, ButtonVariant } from './Button'; import { Button, ButtonVariant } from './Button';
import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; 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 { ButtonSize } from '../Button/types';
import mdx from './Button.mdx'; import mdx from './Button.mdx';
@ -24,9 +25,10 @@ export const simple = () => {
const variant = select('Variant', variants, 'primary'); const variant = select('Variant', variants, 'primary');
const size = select('Size', sizes, 'md'); const size = select('Size', sizes, 'md');
const buttonText = text('text', 'Button'); const buttonText = text('text', 'Button');
const icon = getIconKnob();
return ( 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} {buttonText}
</Button> </Button>
); );

View File

@ -76,6 +76,7 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
// Need to do this because of mismatch between variants in standard buttons and here // Need to do this because of mismatch between variants in standard buttons and here
type StyleProps = Omit<StyleDeps, 'variant'> & { variant: ButtonVariant }; type StyleProps = Omit<StyleDeps, 'variant'> & { variant: ButtonVariant };
export const getButtonStyles = stylesFactory(({ theme, size, variant }: StyleProps) => { export const getButtonStyles = stylesFactory(({ theme, size, variant }: StyleProps) => {
const { padding, fontSize, height } = getPropertiesForButtonSize(theme, size); const { padding, fontSize, height } = getPropertiesForButtonSize(theme, size);
const { background, borderColor, variantStyles } = getPropertiesForVariant(theme, variant); const { background, borderColor, variantStyles } = getPropertiesForVariant(theme, variant);
@ -83,7 +84,6 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StylePro
return { return {
button: cx( button: cx(
css` css`
position: relative;
label: button; label: button;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -111,10 +111,18 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StylePro
${variantStyles} ${variantStyles}
` `
), ),
buttonWithIcon: css`
padding-left: ${theme.spacing.sm};
`,
// used for buttons with icon only
iconButton: css`
padding-right: 0;
`,
iconWrap: css` iconWrap: css`
label: button-icon-wrap; label: button-icon-wrap;
display: flex; & + * {
align-items: center; margin-left: ${theme.spacing.sm};
}
`, `,
}; };
}); });
@ -130,25 +138,25 @@ type CommonProps = {
className?: string; 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 theme = useContext(ThemeContext);
const styles = getButtonStyles({ const styles = getButtonStyles({
theme, theme,
size: props.size || 'md', size: props.size || 'md',
variant: props.variant || 'primary', variant: props.variant || 'primary',
}); });
return <DefaultButton {...props} styles={styles} />; return <DefaultButton {...props} styles={styles} ref={ref} />;
}; });
type ButtonLinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>; type ButtonLinkProps = CommonProps & AnchorHTMLAttributes<HTMLAnchorElement>;
export const LinkButton = (props: ButtonLinkProps) => { export const LinkButton = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>((props, ref) => {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
const styles = getButtonStyles({ const styles = getButtonStyles({
theme, theme,
size: props.size || 'md', size: props.size || 'md',
variant: props.variant || 'primary', variant: props.variant || 'primary',
}); });
return <DefaultLinkButton {...props} styles={styles} />; return <DefaultLinkButton {...props} styles={styles} ref={ref} />;
}; });

View File

@ -55,10 +55,15 @@ export const simple = () => {
const addonAfter = <Button variant="secondary">Load</Button>; const addonAfter = <Button variant="secondary">Load</Button>;
const addonBefore = <div style={{ display: 'flex', alignItems: 'center', padding: '5px' }}>Input</div>; const addonBefore = <div style={{ display: 'flex', alignItems: 'center', padding: '5px' }}>Input</div>;
const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP); const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP);
const suffix = select('Suffix', prefixSuffixOpts, null, VISUAL_GROUP);
let prefixEl: any = prefix; let prefixEl: any = prefix;
if (prefix && prefix.match(/icon-/g)) { if (prefix && prefix.match(/icon-/g)) {
prefixEl = <Icon name={prefix.replace(/icon-/g, '') as IconType} />; 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'; const CONTAINER_GROUP = 'Container options';
// --- // ---
@ -80,6 +85,7 @@ export const simple = () => {
disabled={disabled} disabled={disabled}
invalid={invalid} invalid={invalid}
prefix={prefixEl} prefix={prefixEl}
suffix={suffixEl}
loading={loading} loading={loading}
addonBefore={before && addonBefore} addonBefore={before && addonBefore}
addonAfter={after && addonAfter} addonAfter={after && addonAfter}

View File

@ -1,4 +1,4 @@
import React, { FC, HTMLProps, ReactNode } from 'react'; import React, { HTMLProps, ReactNode } from 'react';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { getFocusStyle, inputSizes, sharedInputStyle } from '../commonStyles'; import { getFocusStyle, inputSizes, sharedInputStyle } from '../commonStyles';
@ -12,6 +12,8 @@ export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'siz
invalid?: boolean; invalid?: boolean;
/** Show an icon as a prefix in the input */ /** Show an icon as a prefix in the input */
prefix?: JSX.Element | string | null; 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 */ /** Show a loading indicator as a suffix in the input */
loading?: boolean; loading?: boolean;
/** Add a component as an addon before the input */ /** Add a component as an addon before the input */
@ -110,6 +112,9 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
&:not(:last-child) { &:not(:last-child) {
padding-right: ${prefixSuffixStaticWidth}; padding-right: ${prefixSuffixStaticWidth};
} }
&[readonly] {
cursor: default;
}
} }
`, `,
@ -124,8 +129,6 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
border-radius: ${borderRadius}; border-radius: ${borderRadius};
height: 100%; height: 100%;
width: 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 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` addon: css`
label: input-addon; label: input-addon;
display: flex; display: flex;
@ -184,7 +191,6 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
border-right: none; border-right: none;
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
left: 0;
` `
), ),
suffix: cx( suffix: cx(
@ -199,11 +205,16 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
right: 0; right: 0;
` `
), ),
loadingIndicator: css`
& + * {
margin-left: ${theme.spacing.xs};
}
`,
}; };
}); });
export const Input: FC<Props> = props => { export const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
const { addonAfter, addonBefore, prefix, invalid, loading, size = 'auto', ...restProps } = props; 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 * 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)). * 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 <input
ref={ref}
className={styles.input} className={styles.input}
{...restProps} {...restProps}
style={{ style={{
@ -235,9 +247,10 @@ export const Input: FC<Props> = props => {
}} }}
/> />
{loading && ( {(suffix || loading) && (
<div className={styles.suffix} ref={suffixRef}> <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>
)} )}
</div> </div>
@ -245,4 +258,4 @@ export const Input: FC<Props> = props => {
{!!addonAfter && <div className={styles.addon}>{addonAfter}</div>} {!!addonAfter && <div className={styles.addon}>{addonAfter}</div>}
</div> </div>
); );
}; });

View File

@ -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>
);
}}
/>
);
}

View File

@ -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} />;
};

View File

@ -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>
);
});

View File

@ -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>
);
}
);

View File

@ -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>
);
};

View 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()}
/>
</>
);
};

View 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 />;
}

View File

@ -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,
});
});
});
});

View 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}
/>
);
}

View File

@ -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>
);
}
);

View File

@ -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);

View File

@ -1,16 +1,27 @@
import React from 'react'; 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 // Ignoring because I couldn't get @types/react-select work wih Torkel's fork
// @ts-ignore // @ts-ignore
import { components } from '@torkelo/react-select'; import { components } from '@torkelo/react-select';
import { useDelayedSwitch } from '../../utils/useDelayedSwitch'; import { useDelayedSwitch } from '../../../utils/useDelayedSwitch';
import { stylesFactory } from '../../themes'; import { stylesFactory, useTheme } from '../../../themes';
import { SlideOutTransition } from '../transitions/SlideOutTransition'; import { SlideOutTransition } from '../../transitions/SlideOutTransition';
import { FadeTransition } from '../transitions/FadeTransition'; import { FadeTransition } from '../../transitions/FadeTransition';
import { Spinner } from '../Spinner/Spinner'; 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` const container = css`
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -27,7 +38,7 @@ const getStyles = stylesFactory(() => {
position: absolute; position: absolute;
`; `;
return { container, item }; return { singleValue, container, item };
}); });
type Props = { type Props = {
@ -41,13 +52,14 @@ type Props = {
export const SingleValue = (props: Props) => { export const SingleValue = (props: Props) => {
const { children, data } = 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 }); const loading = useDelayedSwitch(data.loading || false, { delay: 250, duration: 750 });
return ( return (
<components.SingleValue {...props}> <components.SingleValue {...props}>
<div className={cx('gf-form-select-box__img-value')}> <div className={styles.singleValue}>
{data.imgUrl ? ( {data.imgUrl ? (
<FadeWithImage loading={loading} imgUrl={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 FadeWithImage = (props: { loading: boolean; imgUrl: string }) => {
const styles = getStyles(); const theme = useTheme();
const styles = getStyles(theme);
return ( return (
<div className={styles.container}> <div className={styles.container}>

View File

@ -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>;
};

View File

@ -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};
`,
};
});

View File

@ -25,9 +25,10 @@ export const sharedInputStyle = (theme: GrafanaTheme, invalid = false) => {
font-size: ${theme.typography.size.md}; font-size: ${theme.typography.size.md};
color: ${colors.formInputText}; color: ${colors.formInputText};
border: 1px solid ${borderColor}; border: 1px solid ${borderColor};
padding: 0 ${theme.spacing.sm} 0 ${theme.spacing.sm};
&:hover { &:hover {
border-color: ${colors.formInputBorder}; border-color: ${borderColor};
} }
&:focus { &:focus {
@ -53,7 +54,7 @@ export const inputSizes = () => {
width: 580px; width: 580px;
`, `,
auto: css` auto: css`
width: 100%; width: auto;
`, `,
}; };
}; };

View File

@ -1,6 +1,7 @@
import { getFormStyles } from './getFormStyles'; import { getFormStyles } from './getFormStyles';
import { Label } from './Label'; import { Label } from './Label';
import { Input } from './Input/Input'; import { Input } from './Input/Input';
import { Select } from './Select/Select';
import { Form } from './Form'; import { Form } from './Form';
import { Field } from './Field'; import { Field } from './Field';
import { Button } from './Button'; import { Button } from './Button';
@ -12,6 +13,7 @@ const Forms = {
Form, Form,
Field, Field,
Button, Button,
Select,
}; };
export default Forms; export default Forms;

View File

@ -6,6 +6,8 @@ import { IconType } from './types';
export interface IconProps { export interface IconProps {
name: IconType; name: IconType;
className?: string; className?: string;
onClick?: () => void;
onMouseDown?: React.MouseEventHandler;
} }
const getIconStyles = stylesFactory(() => { 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(); 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'; Icon.displayName = 'Icon';

View File

@ -24,7 +24,7 @@ SelectStories.add('default', () => {
{(value, updateValue) => { {(value, updateValue) => {
return ( return (
<Select <Select
value={value} placeholder="Choose..."
options={options} options={options}
onChange={value => { onChange={value => {
action('onChanged fired')(value); action('onChanged fired')(value);
@ -39,7 +39,8 @@ SelectStories.add('default', () => {
SelectStories.add('With allowCustomValue', () => { SelectStories.add('With allowCustomValue', () => {
const intialState: SelectableValue<string> = { label: 'A label', value: 'A value' }; 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:', [ const options = object<Array<SelectableValue<string>>>('Options:', [
intialState, intialState,
{ label: 'Another label', value: 'Another value' }, { label: 'Another label', value: 'Another value' },
@ -50,7 +51,8 @@ SelectStories.add('With allowCustomValue', () => {
{(value, updateValue) => { {(value, updateValue) => {
return ( return (
<Select <Select
value={value} // value={value}
placeholder="Choose..."
options={options} options={options}
allowCustomValue={true} allowCustomValue={true}
onChange={value => { onChange={value => {

View File

@ -14,54 +14,37 @@ import { components } from '@torkelo/react-select';
// Components // Components
import { SelectOption } from './SelectOption'; import { SelectOption } from './SelectOption';
import { SingleValue } from './SingleValue'; import { SelectOptionGroup } from '../Forms/Select/SelectOptionGroup';
import SelectOptionGroup from './SelectOptionGroup'; import { SingleValue } from '../Forms/Select/SingleValue';
import { SelectCommonProps, SelectAsyncProps } from '../Forms/Select/SelectBase';
import IndicatorsContainer from './IndicatorsContainer'; import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage'; import NoOptionsMessage from './NoOptionsMessage';
import resetSelectStyles from './resetSelectStyles'; import resetSelectStyles from '../Forms/Select/resetSelectStyles';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { PopoverContent } from '../Tooltip/Tooltip'; import { PopoverContent } from '../Tooltip/Tooltip';
import { Tooltip } from '../Tooltip/Tooltip'; import { Tooltip } from '../Tooltip/Tooltip';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
export interface CommonProps<T> { /**
defaultValue?: any; * Changes in new selects:
getOptionLabel?: (item: SelectableValue<T>) => string; * - noOptionsMessage & loadingMessage is of string type
getOptionValue?: (item: SelectableValue<T>) => string; * - isDisabled is renamed to disabled
onChange: (item: SelectableValue<T>) => {} | void; */
placeholder?: string; type LegacyCommonProps<T> = Omit<SelectCommonProps<T>, 'noOptionsMessage' | 'disabled' | 'value'>;
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;
}
export interface SelectProps<T> extends CommonProps<T> { interface AsyncProps<T> extends LegacyCommonProps<T>, Omit<SelectAsyncProps<T>, 'loadingMessage'> {
options: Array<SelectableValue<T>>;
}
interface AsyncProps<T> extends CommonProps<T> {
defaultOptions: boolean;
loadOptions: (query: string) => Promise<Array<SelectableValue<T>>>;
loadingMessage?: () => string; 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) => { export const MenuList = (props: any) => {
@ -73,9 +56,8 @@ export const MenuList = (props: any) => {
</components.MenuList> </components.MenuList>
); );
}; };
export class Select<T> extends PureComponent<LegacySelectProps<T>> {
export class Select<T> extends PureComponent<SelectProps<T>> { static defaultProps: Partial<LegacySelectProps<any>> = {
static defaultProps: Partial<SelectProps<any>> = {
className: '', className: '',
isDisabled: false, isDisabled: false,
isSearchable: true, 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 selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
const selectComponents = { ...Select.defaultProps.components, ...components }; const selectComponents = { ...Select.defaultProps.components, ...components };
return ( return (
<WrapInTooltip onCloseMenu={onCloseMenu} onOpenMenu={onOpenMenu} tooltipContent={tooltipContent} isOpen={isOpen}> <WrapInTooltip onCloseMenu={onCloseMenu} onOpenMenu={onOpenMenu} tooltipContent={tooltipContent} isOpen={isOpen}>
{(onOpenMenuInternal, onCloseMenuInternal) => { {(onOpenMenuInternal, onCloseMenuInternal) => {
@ -170,7 +151,7 @@ export class Select<T> extends PureComponent<SelectProps<T>> {
onBlur={onBlur} onBlur={onBlur}
openMenuOnFocus={openMenuOnFocus} openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={maxMenuHeight} maxMenuHeight={maxMenuHeight}
noOptionsMessage={noOptionsMessage} noOptionsMessage={() => noOptionsMessage}
isMulti={isMulti} isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue} backspaceRemovesValue={backspaceRemovesValue}
menuIsOpen={isOpen} menuIsOpen={isOpen}
@ -262,7 +243,7 @@ export class AsyncSelect<T> extends PureComponent<AsyncProps<T>> {
defaultOptions={defaultOptions} defaultOptions={defaultOptions}
placeholder={placeholder || 'Choose'} placeholder={placeholder || 'Choose'}
styles={resetSelectStyles()} styles={resetSelectStyles()}
loadingMessage={loadingMessage} loadingMessage={() => loadingMessage}
noOptionsMessage={noOptionsMessage} noOptionsMessage={noOptionsMessage}
isDisabled={isDisabled} isDisabled={isDisabled}
isSearchable={isSearchable} isSearchable={isSearchable}

View File

@ -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>
);
}
}

View File

@ -12,7 +12,7 @@ export * from './Button/Button';
export { Select, AsyncSelect } from './Select/Select'; export { Select, AsyncSelect } from './Select/Select';
export { IndicatorsContainer } from './Select/IndicatorsContainer'; export { IndicatorsContainer } from './Select/IndicatorsContainer';
export { NoOptionsMessage } from './Select/NoOptionsMessage'; 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 { ButtonSelect } from './Select/ButtonSelect';
export { Cascader, CascaderOption } from './Cascader/Cascader'; export { Cascader, CascaderOption } from './Cascader/Cascader';

View 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);

View File

@ -361,7 +361,7 @@ Array [
className="css-0 gf-form-select-box__single-value" className="css-0 gf-form-select-box__single-value"
> >
<div <div
className="gf-form-select-box__img-value" className="css-38iae9-singleValue"
> >
stackdriver auto stackdriver auto
</div> </div>