mirror of https://github.com/grafana/grafana
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 getFormStylespull/20467/head
parent
785584a690
commit
99e635071e
@ -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> |
||||||
|
); |
||||||
|
}; |
||||||
@ -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> |
||||||
|
); |
||||||
|
}; |
||||||
@ -1,9 +1,11 @@ |
|||||||
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; |
||||||
|
|||||||
@ -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]; |
||||||
|
}; |
||||||
Loading…
Reference in new issue