Commit 99e63507 by Peter Holmberg Committed by GitHub

Forms: New Input component (#20159)

* Adding component, story and documentation file

* forgot files

* Add label and formvalidation

* fix for error/invalid message

* fixing font color when input is disabled

* red border if invalid

* fixing props and label margin

* added support for icon in input

* support for button and loading state

* redoing some of the markup

* fixing height on addons

* Adding some basic documentation

* remove not used types file

* Add some more knobs

* move component to it's own directory, updated styling

* Adding component, story and documentation file

* forgot files

* Add label and formvalidation

* fix for error/invalid message

* fixing font color when input is disabled

* red border if invalid

* fixing props and label margin

* added support for icon in input

* support for button and loading state

* redoing some of the markup

* fixing height on addons

* Adding some basic documentation

* remove not used types file

* Add some more knobs

* move component to it's own directory, updated styling

* Add Icon component

* Add useClientRect helper hook

* Add missing Icon types

* Simplify Inputs styling (POC)

* Render theme knob in a separate group

* Update packages/grafana-ui/src/components/Forms/Input/Input.tsx

Co-Authored-By: Peter Holmberg <peterholmberg@users.noreply.github.com>

* Update packages/grafana-ui/src/components/Forms/Input/Input.tsx

* Improve comment

* Restore increase/decrease spinner on number inputs

* Add period

* use input color variables

* fix test

* Expose input styles from getFormStyles
parent 785584a6
...@@ -225,6 +225,7 @@ export interface GrafanaTheme extends GrafanaThemeCommons { ...@@ -225,6 +225,7 @@ export interface GrafanaTheme extends GrafanaThemeCommons {
formInputBorderInvalid: string; formInputBorderInvalid: string;
formInputFocusOutline: string; formInputFocusOutline: string;
formInputText: string; formInputText: string;
formInputDisabledText: string;
formInputTextStrong: string; formInputTextStrong: string;
formInputTextWhite: string; formInputTextWhite: string;
formValidationMessageText: string; formValidationMessageText: string;
......
...@@ -24,12 +24,12 @@ export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaThem ...@@ -24,12 +24,12 @@ export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaThem
content: ''; content: '';
position: absolute; position: absolute;
left: 9px; left: 9px;
top: -5px; top: -4px;
width: 0; width: 0;
height: 0; height: 0;
border-left: 5px solid transparent; border-left: 4px solid transparent;
border-right: 5px solid transparent; border-right: 4px solid transparent;
border-bottom: 5px solid ${theme.colors.formValidationMessageBg}; border-bottom: 4px solid ${theme.colors.formValidationMessageBg};
} }
`, `,
fieldValidationMessageIcon: css` fieldValidationMessageIcon: css`
......
import { Props } from '@storybook/addon-docs/blocks';
import { Input } from './Input';
# Input
<Props of={Input} />
import React from 'react';
import { boolean, text, select, number } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
import { Input } from './Input';
import { Button } from '../Button';
import mdx from './Input.mdx';
import { getAvailableIcons, IconType } from '../../Icon/types';
import { KeyValue } from '@grafana/data';
import { Icon } from '../../Icon/Icon';
export default {
title: 'UI/Forms/Input',
component: Input,
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
},
};
export const simple = () => {
const prefixSuffixOpts = {
None: null,
Text: '$',
...getAvailableIcons().reduce<KeyValue<string>>((prev, c) => {
return {
...prev,
[`Icon: ${c}`]: `icon-${c}`,
};
}, {}),
};
const BEHAVIOUR_GROUP = 'Behaviour props';
// ---
const type = select(
'Type',
{
text: 'text',
password: 'password',
number: 'number',
},
'text',
BEHAVIOUR_GROUP
);
const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP);
const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP);
const loading = boolean('Loading', false, BEHAVIOUR_GROUP);
const VISUAL_GROUP = 'Visual options';
// ---
const placeholder = text('Placeholder', 'Enter your name here...', VISUAL_GROUP);
const before = boolean('Addon before', false, VISUAL_GROUP);
const after = boolean('Addon after', false, VISUAL_GROUP);
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);
let prefixEl: any = prefix;
if (prefix && prefix.match(/icon-/g)) {
prefixEl = <Icon name={prefix.replace(/icon-/g, '') as IconType} />;
}
const CONTAINER_GROUP = 'Container options';
// ---
const containerWidth = number(
'Container width',
300,
{
range: true,
min: 100,
max: 500,
step: 10,
},
CONTAINER_GROUP
);
return (
<div style={{ width: containerWidth }}>
<Input
disabled={disabled}
invalid={invalid}
prefix={prefixEl}
loading={loading}
addonBefore={before && addonBefore}
addonAfter={after && addonAfter}
type={type}
placeholder={placeholder}
/>
</div>
);
};
import React, { FC, HTMLProps, ReactNode } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion';
import { getFocusStyle } from '../commonStyles';
import { stylesFactory, useTheme } from '../../../themes';
import { Icon } from '../../Icon/Icon';
import { useClientRect } from '../../../utils/useClientRect';
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix'> {
/** Show an invalid state around the input */
invalid?: boolean;
/** Show an icon as a prefix in the input */
prefix?: 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 */
addonBefore?: ReactNode;
/** Add a component as an addon after the input */
addonAfter?: ReactNode;
}
interface StyleDeps {
theme: GrafanaTheme;
invalid: boolean;
}
export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDeps) => {
const colors = theme.colors;
const inputBorderColor = invalid ? colors.redBase : colors.formInputBorder;
const borderRadius = theme.border.radius.sm;
const height = theme.spacing.formInputHeight;
const prefixSuffixStaticWidth = '28px';
const prefixSuffix = css`
position: absolute;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
flex-grow: 0;
flex-shrink: 0;
font-size: ${theme.typography.size.md};
height: 100%;
/* Min width specified for prefix/suffix classes used outside React component*/
min-width: ${prefixSuffixStaticWidth};
`;
return {
// Wraps inputWraper and addons
wrapper: cx(
css`
label: input-wrapper;
display: flex;
width: 100%;
height: ${height};
border-radius: ${borderRadius};
margin-bottom: ${invalid ? theme.spacing.formSpacingBase / 2 : theme.spacing.formSpacingBase * 2}px;
&:hover {
> .prefix,
.suffix,
.input {
border-color: ${invalid ? colors.redBase : colors.formInputBorder};
}
}
`
),
// Wraps input and prefix/suffix
inputWrapper: css`
label: input-inputWrapper;
position: relative;
flex-grow: 1;
/* we want input to be above addons, especially for focused state */
z-index: 1;
/* when input rendered with addon before only*/
&:not(:first-child):last-child {
> input {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
/* when input rendered with addon after only*/
&:first-child:not(:last-child) {
> input {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
/* when rendered with addon before and after */
&:not(:first-child):not(:last-child) {
> input {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
input {
/* paddings specified for classes used outside React component */
&:not(:first-child) {
padding-left: ${prefixSuffixStaticWidth};
}
&:not(:last-child) {
padding-right: ${prefixSuffixStaticWidth};
}
}
`,
input: cx(
getFocusStyle(theme),
css`
label: input-input;
position: relative;
z-index: 0;
flex-grow: 1;
color: ${colors.formInputText};
background-color: ${colors.formInputBg};
border: 1px solid ${inputBorderColor};
border-radius: ${borderRadius};
height: 100%;
width: 100%;
padding: 0 ${theme.spacing.sm} 0 ${theme.spacing.sm};
font-size: ${theme.typography.size.md};
&:disabled {
background-color: ${colors.formInputBgDisabled};
color: ${colors.formInputDisabledText};
}
/*
Restoring increase/decrease spinner on number inputs. Overwriting rules implemented in
https://github.com/grafana/grafana/commit/488fe62f158a9e0a0bced2b678ada5d43cf3998e.
*/
&[type='number']::-webkit-outer-spin-button,
&[type='number']::-webkit-inner-spin-button {
-webkit-appearance: inner-spin-button !important;
opacity: 1;
}
&[type='number'] {
-moz-appearance: number-input;
}
`
),
addon: css`
label: input-addon;
display: flex;
justify-content: center;
align-items: center;
flex-grow: 0;
flex-shrink: 0;
position: relative;
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
> :last-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
> :first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
> *:focus {
/* we want anything that has focus and is an addon to be above input */
z-index: 2;
}
}
`,
prefix: cx(
prefixSuffix,
css`
label: input-prefix;
padding-left: ${theme.spacing.sm};
padding-right: ${theme.spacing.xs};
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
left: 0;
`
),
suffix: cx(
prefixSuffix,
css`
label: input-suffix;
padding-right: ${theme.spacing.sm};
padding-left: ${theme.spacing.xs};
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
right: 0;
`
),
};
});
export const Input: FC<Props> = props => {
const { addonAfter, addonBefore, prefix, invalid, loading, ...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)).
* Thanks to that prefix/suffix do not overflow the input element itself.
*/
const [prefixRect, prefixRef] = useClientRect<HTMLDivElement>();
const [suffixRect, suffixRef] = useClientRect<HTMLDivElement>();
const theme = useTheme();
const styles = getInputStyles({ theme, invalid: !!invalid });
return (
<div className={styles.wrapper}>
{!!addonBefore && <div className={styles.addon}>{addonBefore}</div>}
<div className={styles.inputWrapper}>
{prefix && (
<div className={styles.prefix} ref={prefixRef}>
{prefix}
</div>
)}
<input
className={styles.input}
{...restProps}
style={{
paddingLeft: prefixRect ? prefixRect.width : undefined,
paddingRight: suffixRect ? suffixRect.width : undefined,
}}
/>
{loading && (
<div className={styles.suffix} ref={suffixRef}>
<Icon name="spinner" className="fa-spin" />
</div>
)}
</div>
{!!addonAfter && <div className={styles.addon}>{addonAfter}</div>}
</div>
);
};
...@@ -2,19 +2,8 @@ import { css } from 'emotion'; ...@@ -2,19 +2,8 @@ import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
export const getFocusStyle = (theme: GrafanaTheme) => css` export const getFocusStyle = (theme: GrafanaTheme) => css`
&[focus],
&:focus { &:focus {
&:before { outline: none;
content: ''; box-shadow: 0 0 0 2px ${theme.colors.blueLight};
position: absolute;
border: 2px solid ${theme.colors.blueLight};
border-radius: ${theme.border.radius.lg};
background-color: ${theme.colors.bodyBg};
height: calc(100% + 8px);
width: calc(100% + 8px);
top: -4px;
left: -4px;
z-index: -1;
}
} }
`; `;
...@@ -5,9 +5,10 @@ import { getLegendStyles } from './Legend'; ...@@ -5,9 +5,10 @@ import { getLegendStyles } from './Legend';
import { getFieldValidationMessageStyles } from './FieldValidationMessage'; import { getFieldValidationMessageStyles } from './FieldValidationMessage';
import { getButtonStyles, ButtonVariant } from './Button'; import { getButtonStyles, ButtonVariant } from './Button';
import { ButtonSize } from '../Button/types'; import { ButtonSize } from '../Button/types';
import { getInputStyles } from './Input/Input';
export const getFormStyles = stylesFactory( export const getFormStyles = stylesFactory(
(theme: GrafanaTheme, options: { variant: ButtonVariant; size: ButtonSize }) => { (theme: GrafanaTheme, options: { variant: ButtonVariant; size: ButtonSize; invalid: boolean }) => {
return { return {
...getLabelStyles(theme), ...getLabelStyles(theme),
...getLegendStyles(theme), ...getLegendStyles(theme),
...@@ -17,6 +18,7 @@ export const getFormStyles = stylesFactory( ...@@ -17,6 +18,7 @@ export const getFormStyles = stylesFactory(
variant: options.variant, variant: options.variant,
size: options.size, size: options.size,
}), }),
...getInputStyles({ theme, invalid: options.invalid }),
}; };
} }
); );
import { getFormStyles } from './getFormStyles'; import { getFormStyles } from './getFormStyles';
import { Label } from './Label'; import { Label } from './Label';
import { Input } from './Input/Input';
const Forms = { const Forms = {
getFormStyles, getFormStyles,
Label: Label, Label: Label,
Input: Input,
}; };
export default Forms; export default Forms;
...@@ -129,8 +129,9 @@ exports[`Render should render with base threshold 1`] = ` ...@@ -129,8 +129,9 @@ exports[`Render should render with base threshold 1`] = `
"formInputBorderActive": "#5794f2", "formInputBorderActive": "#5794f2",
"formInputBorderHover": "#464c54", "formInputBorderHover": "#464c54",
"formInputBorderInvalid": "#e02f44", "formInputBorderInvalid": "#e02f44",
"formInputDisabledText": "#9fa7b3",
"formInputFocusOutline": "#1f60c4", "formInputFocusOutline": "#1f60c4",
"formInputText": "#9fa7b3", "formInputText": "#c7d0d9",
"formInputTextStrong": "#c7d0d9", "formInputTextStrong": "#c7d0d9",
"formInputTextWhite": "#ffffff", "formInputTextWhite": "#ffffff",
"formLabel": "#9fa7b3", "formLabel": "#9fa7b3",
...@@ -339,8 +340,9 @@ exports[`Render should render with base threshold 1`] = ` ...@@ -339,8 +340,9 @@ exports[`Render should render with base threshold 1`] = `
"formInputBorderActive": "#5794f2", "formInputBorderActive": "#5794f2",
"formInputBorderHover": "#464c54", "formInputBorderHover": "#464c54",
"formInputBorderInvalid": "#e02f44", "formInputBorderInvalid": "#e02f44",
"formInputDisabledText": "#9fa7b3",
"formInputFocusOutline": "#1f60c4", "formInputFocusOutline": "#1f60c4",
"formInputText": "#9fa7b3", "formInputText": "#c7d0d9",
"formInputTextStrong": "#c7d0d9", "formInputTextStrong": "#c7d0d9",
"formInputTextWhite": "#ffffff", "formInputTextWhite": "#ffffff",
"formLabel": "#9fa7b3", "formLabel": "#9fa7b3",
......
...@@ -87,7 +87,8 @@ const darkTheme: GrafanaTheme = { ...@@ -87,7 +87,8 @@ const darkTheme: GrafanaTheme = {
formInputBorderActive: basicColors.blue95, formInputBorderActive: basicColors.blue95,
formInputBorderInvalid: basicColors.red88, formInputBorderInvalid: basicColors.red88,
formInputFocusOutline: basicColors.blue77, formInputFocusOutline: basicColors.blue77,
formInputText: basicColors.gray70, formInputText: basicColors.gray85,
formInputDisabledText: basicColors.gray70,
formInputTextStrong: basicColors.gray85, formInputTextStrong: basicColors.gray85,
formInputTextWhite: basicColors.white, formInputTextWhite: basicColors.white,
formValidationMessageText: basicColors.white, formValidationMessageText: basicColors.white,
......
...@@ -97,7 +97,7 @@ const theme: GrafanaThemeCommons = { ...@@ -97,7 +97,7 @@ const theme: GrafanaThemeCommons = {
formInputMargin: `${SPACING_BASE * 2}px`, formInputMargin: `${SPACING_BASE * 2}px`,
formLabelPadding: '0 0 0 2px', formLabelPadding: '0 0 0 2px',
formLabelMargin: '0 0 4px 0', formLabelMargin: `0 0 ${SPACING_BASE / 2 + 'px'} 0`,
formValidationMessagePadding: '4px 8px', formValidationMessagePadding: '4px 8px',
}, },
border: { border: {
......
...@@ -88,7 +88,8 @@ const lightTheme: GrafanaTheme = { ...@@ -88,7 +88,8 @@ const lightTheme: GrafanaTheme = {
formInputBorderActive: basicColors.blue77, formInputBorderActive: basicColors.blue77,
formInputBorderInvalid: basicColors.red88, formInputBorderInvalid: basicColors.red88,
formInputFocusOutline: basicColors.blue95, formInputFocusOutline: basicColors.blue95,
formInputText: basicColors.gray33, formInputText: basicColors.gray25,
formInputDisabledText: basicColors.gray33,
formInputTextStrong: basicColors.gray25, formInputTextStrong: basicColors.gray25,
formInputTextWhite: basicColors.white, formInputTextWhite: basicColors.white,
formValidationMessageText: basicColors.white, formValidationMessageText: basicColors.white,
......
...@@ -16,7 +16,8 @@ const ThemableStory: React.FunctionComponent<{ handleSassThemeChange: SassThemeC ...@@ -16,7 +16,8 @@ const ThemableStory: React.FunctionComponent<{ handleSassThemeChange: SassThemeC
Light: GrafanaThemeType.Light, Light: GrafanaThemeType.Light,
Dark: GrafanaThemeType.Dark, Dark: GrafanaThemeType.Dark,
}, },
GrafanaThemeType.Dark GrafanaThemeType.Dark,
'Theme'
); );
handleSassThemeChange(themeKnob); handleSassThemeChange(themeKnob);
......
import { useState, useCallback } from 'react';
export const useClientRect = <T extends HTMLElement>(): [{ width: number; height: number } | null, React.Ref<T>] => {
const [rect, setRect] = useState<{ width: number; height: number } | null>(null);
const ref = useCallback((node: T) => {
if (node !== null) {
setRect(node.getBoundingClientRect());
}
}, []);
return [rect, ref];
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment