mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
@@ -225,6 +225,7 @@ export interface GrafanaTheme extends GrafanaThemeCommons {
|
||||
formInputBorderInvalid: string;
|
||||
formInputFocusOutline: string;
|
||||
formInputText: string;
|
||||
formInputDisabledText: string;
|
||||
formInputTextStrong: string;
|
||||
formInputTextWhite: string;
|
||||
formValidationMessageText: string;
|
||||
|
@@ -24,12 +24,12 @@ export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaThem
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: -5px;
|
||||
top: -4px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-bottom: 5px solid ${theme.colors.formValidationMessageBg};
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 4px solid ${theme.colors.formValidationMessageBg};
|
||||
}
|
||||
`,
|
||||
fieldValidationMessageIcon: css`
|
||||
|
6
packages/grafana-ui/src/components/Forms/Input/Input.mdx
Normal file
6
packages/grafana-ui/src/components/Forms/Input/Input.mdx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Props } from '@storybook/addon-docs/blocks';
|
||||
import { Input } from './Input';
|
||||
|
||||
# Input
|
||||
|
||||
<Props of={Input} />
|
@@ -0,0 +1,91 @@
|
||||
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>
|
||||
);
|
||||
};
|
255
packages/grafana-ui/src/components/Forms/Input/Input.tsx
Normal file
255
packages/grafana-ui/src/components/Forms/Input/Input.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
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';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
export const getFocusStyle = (theme: GrafanaTheme) => css`
|
||||
&[focus],
|
||||
&:focus {
|
||||
&:before {
|
||||
content: '';
|
||||
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;
|
||||
}
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px ${theme.colors.blueLight};
|
||||
}
|
||||
`;
|
||||
|
@@ -5,9 +5,10 @@ import { getLegendStyles } from './Legend';
|
||||
import { getFieldValidationMessageStyles } from './FieldValidationMessage';
|
||||
import { getButtonStyles, ButtonVariant } from './Button';
|
||||
import { ButtonSize } from '../Button/types';
|
||||
import { getInputStyles } from './Input/Input';
|
||||
|
||||
export const getFormStyles = stylesFactory(
|
||||
(theme: GrafanaTheme, options: { variant: ButtonVariant; size: ButtonSize }) => {
|
||||
(theme: GrafanaTheme, options: { variant: ButtonVariant; size: ButtonSize; invalid: boolean }) => {
|
||||
return {
|
||||
...getLabelStyles(theme),
|
||||
...getLegendStyles(theme),
|
||||
@@ -17,6 +18,7 @@ export const getFormStyles = stylesFactory(
|
||||
variant: options.variant,
|
||||
size: options.size,
|
||||
}),
|
||||
...getInputStyles({ theme, invalid: options.invalid }),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import { getFormStyles } from './getFormStyles';
|
||||
import { Label } from './Label';
|
||||
import { Input } from './Input/Input';
|
||||
|
||||
const Forms = {
|
||||
getFormStyles,
|
||||
Label: Label,
|
||||
Input: Input,
|
||||
};
|
||||
|
||||
export default Forms;
|
||||
|
@@ -129,8 +129,9 @@ exports[`Render should render with base threshold 1`] = `
|
||||
"formInputBorderActive": "#5794f2",
|
||||
"formInputBorderHover": "#464c54",
|
||||
"formInputBorderInvalid": "#e02f44",
|
||||
"formInputDisabledText": "#9fa7b3",
|
||||
"formInputFocusOutline": "#1f60c4",
|
||||
"formInputText": "#9fa7b3",
|
||||
"formInputText": "#c7d0d9",
|
||||
"formInputTextStrong": "#c7d0d9",
|
||||
"formInputTextWhite": "#ffffff",
|
||||
"formLabel": "#9fa7b3",
|
||||
@@ -339,8 +340,9 @@ exports[`Render should render with base threshold 1`] = `
|
||||
"formInputBorderActive": "#5794f2",
|
||||
"formInputBorderHover": "#464c54",
|
||||
"formInputBorderInvalid": "#e02f44",
|
||||
"formInputDisabledText": "#9fa7b3",
|
||||
"formInputFocusOutline": "#1f60c4",
|
||||
"formInputText": "#9fa7b3",
|
||||
"formInputText": "#c7d0d9",
|
||||
"formInputTextStrong": "#c7d0d9",
|
||||
"formInputTextWhite": "#ffffff",
|
||||
"formLabel": "#9fa7b3",
|
||||
|
@@ -87,7 +87,8 @@ const darkTheme: GrafanaTheme = {
|
||||
formInputBorderActive: basicColors.blue95,
|
||||
formInputBorderInvalid: basicColors.red88,
|
||||
formInputFocusOutline: basicColors.blue77,
|
||||
formInputText: basicColors.gray70,
|
||||
formInputText: basicColors.gray85,
|
||||
formInputDisabledText: basicColors.gray70,
|
||||
formInputTextStrong: basicColors.gray85,
|
||||
formInputTextWhite: basicColors.white,
|
||||
formValidationMessageText: basicColors.white,
|
||||
|
@@ -97,7 +97,7 @@ const theme: GrafanaThemeCommons = {
|
||||
|
||||
formInputMargin: `${SPACING_BASE * 2}px`,
|
||||
formLabelPadding: '0 0 0 2px',
|
||||
formLabelMargin: '0 0 4px 0',
|
||||
formLabelMargin: `0 0 ${SPACING_BASE / 2 + 'px'} 0`,
|
||||
formValidationMessagePadding: '4px 8px',
|
||||
},
|
||||
border: {
|
||||
|
@@ -88,7 +88,8 @@ const lightTheme: GrafanaTheme = {
|
||||
formInputBorderActive: basicColors.blue77,
|
||||
formInputBorderInvalid: basicColors.red88,
|
||||
formInputFocusOutline: basicColors.blue95,
|
||||
formInputText: basicColors.gray33,
|
||||
formInputText: basicColors.gray25,
|
||||
formInputDisabledText: basicColors.gray33,
|
||||
formInputTextStrong: basicColors.gray25,
|
||||
formInputTextWhite: basicColors.white,
|
||||
formValidationMessageText: basicColors.white,
|
||||
|
@@ -16,7 +16,8 @@ const ThemableStory: React.FunctionComponent<{ handleSassThemeChange: SassThemeC
|
||||
Light: GrafanaThemeType.Light,
|
||||
Dark: GrafanaThemeType.Dark,
|
||||
},
|
||||
GrafanaThemeType.Dark
|
||||
GrafanaThemeType.Dark,
|
||||
'Theme'
|
||||
);
|
||||
|
||||
handleSassThemeChange(themeKnob);
|
||||
|
11
packages/grafana-ui/src/utils/useClientRect.ts
Normal file
11
packages/grafana-ui/src/utils/useClientRect.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
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];
|
||||
};
|
Reference in New Issue
Block a user