From 99e635071e955c210f63232f52eb8d122dc80b4f Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Tue, 19 Nov 2019 10:42:59 +0100 Subject: [PATCH] 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 * 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 --- packages/grafana-data/src/types/theme.ts | 1 + .../Forms/FieldValidationMessage.tsx | 8 +- .../src/components/Forms/Input/Input.mdx | 6 + .../components/Forms/Input/Input.story.tsx | 91 +++++++ .../src/components/Forms/Input/Input.tsx | 255 ++++++++++++++++++ .../src/components/Forms/commonStyles.ts | 15 +- .../src/components/Forms/getFormStyles.ts | 4 +- .../grafana-ui/src/components/Forms/index.ts | 2 + .../ThresholdsEditor.test.tsx.snap | 6 +- packages/grafana-ui/src/themes/dark.ts | 3 +- packages/grafana-ui/src/themes/default.ts | 2 +- packages/grafana-ui/src/themes/light.ts | 3 +- .../src/utils/storybook/withTheme.tsx | 3 +- .../grafana-ui/src/utils/useClientRect.ts | 11 + 14 files changed, 386 insertions(+), 24 deletions(-) create mode 100644 packages/grafana-ui/src/components/Forms/Input/Input.mdx create mode 100644 packages/grafana-ui/src/components/Forms/Input/Input.story.tsx create mode 100644 packages/grafana-ui/src/components/Forms/Input/Input.tsx create mode 100644 packages/grafana-ui/src/utils/useClientRect.ts diff --git a/packages/grafana-data/src/types/theme.ts b/packages/grafana-data/src/types/theme.ts index 538c08bdcd4..8efa746a7f6 100644 --- a/packages/grafana-data/src/types/theme.ts +++ b/packages/grafana-data/src/types/theme.ts @@ -225,6 +225,7 @@ export interface GrafanaTheme extends GrafanaThemeCommons { formInputBorderInvalid: string; formInputFocusOutline: string; formInputText: string; + formInputDisabledText: string; formInputTextStrong: string; formInputTextWhite: string; formValidationMessageText: string; diff --git a/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx b/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx index 99f8249a0e5..4b565aa8df0 100644 --- a/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx +++ b/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx @@ -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` diff --git a/packages/grafana-ui/src/components/Forms/Input/Input.mdx b/packages/grafana-ui/src/components/Forms/Input/Input.mdx new file mode 100644 index 00000000000..8f3bb755768 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Input/Input.mdx @@ -0,0 +1,6 @@ +import { Props } from '@storybook/addon-docs/blocks'; +import { Input } from './Input'; + +# Input + + diff --git a/packages/grafana-ui/src/components/Forms/Input/Input.story.tsx b/packages/grafana-ui/src/components/Forms/Input/Input.story.tsx new file mode 100644 index 00000000000..26f9a0ab590 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Input/Input.story.tsx @@ -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>((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 = ; + const addonBefore =
Input
; + const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP); + let prefixEl: any = prefix; + if (prefix && prefix.match(/icon-/g)) { + prefixEl = ; + } + + const CONTAINER_GROUP = 'Container options'; + // --- + const containerWidth = number( + 'Container width', + 300, + { + range: true, + min: 100, + max: 500, + step: 10, + }, + CONTAINER_GROUP + ); + + return ( +
+ +
+ ); +}; diff --git a/packages/grafana-ui/src/components/Forms/Input/Input.tsx b/packages/grafana-ui/src/components/Forms/Input/Input.tsx new file mode 100644 index 00000000000..61497c8c64b --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Input/Input.tsx @@ -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, '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 => { + 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(); + const [suffixRect, suffixRef] = useClientRect(); + const theme = useTheme(); + + const styles = getInputStyles({ theme, invalid: !!invalid }); + + return ( +
+ {!!addonBefore &&
{addonBefore}
} + +
+ {prefix && ( +
+ {prefix} +
+ )} + + + + {loading && ( +
+ +
+ )} +
+ + {!!addonAfter &&
{addonAfter}
} +
+ ); +}; diff --git a/packages/grafana-ui/src/components/Forms/commonStyles.ts b/packages/grafana-ui/src/components/Forms/commonStyles.ts index 140f5f9f9cc..061615fea98 100644 --- a/packages/grafana-ui/src/components/Forms/commonStyles.ts +++ b/packages/grafana-ui/src/components/Forms/commonStyles.ts @@ -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}; } `; diff --git a/packages/grafana-ui/src/components/Forms/getFormStyles.ts b/packages/grafana-ui/src/components/Forms/getFormStyles.ts index eff75f1ae3e..7f4bea65d85 100644 --- a/packages/grafana-ui/src/components/Forms/getFormStyles.ts +++ b/packages/grafana-ui/src/components/Forms/getFormStyles.ts @@ -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 }), }; } ); diff --git a/packages/grafana-ui/src/components/Forms/index.ts b/packages/grafana-ui/src/components/Forms/index.ts index 446a2a1d026..0e578623beb 100644 --- a/packages/grafana-ui/src/components/Forms/index.ts +++ b/packages/grafana-ui/src/components/Forms/index.ts @@ -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; diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap b/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap index 63ce15319c8..6b45eb7bf33 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap +++ b/packages/grafana-ui/src/components/ThresholdsEditor/__snapshots__/ThresholdsEditor.test.tsx.snap @@ -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", diff --git a/packages/grafana-ui/src/themes/dark.ts b/packages/grafana-ui/src/themes/dark.ts index a4b49559950..c54f3399bce 100644 --- a/packages/grafana-ui/src/themes/dark.ts +++ b/packages/grafana-ui/src/themes/dark.ts @@ -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, diff --git a/packages/grafana-ui/src/themes/default.ts b/packages/grafana-ui/src/themes/default.ts index 72e2f3f03b9..66e43489682 100644 --- a/packages/grafana-ui/src/themes/default.ts +++ b/packages/grafana-ui/src/themes/default.ts @@ -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: { diff --git a/packages/grafana-ui/src/themes/light.ts b/packages/grafana-ui/src/themes/light.ts index 475c0046dd8..73cc80d9ba1 100644 --- a/packages/grafana-ui/src/themes/light.ts +++ b/packages/grafana-ui/src/themes/light.ts @@ -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, diff --git a/packages/grafana-ui/src/utils/storybook/withTheme.tsx b/packages/grafana-ui/src/utils/storybook/withTheme.tsx index d189bb041f9..7586589a478 100644 --- a/packages/grafana-ui/src/utils/storybook/withTheme.tsx +++ b/packages/grafana-ui/src/utils/storybook/withTheme.tsx @@ -16,7 +16,8 @@ const ThemableStory: React.FunctionComponent<{ handleSassThemeChange: SassThemeC Light: GrafanaThemeType.Light, Dark: GrafanaThemeType.Dark, }, - GrafanaThemeType.Dark + GrafanaThemeType.Dark, + 'Theme' ); handleSassThemeChange(themeKnob); diff --git a/packages/grafana-ui/src/utils/useClientRect.ts b/packages/grafana-ui/src/utils/useClientRect.ts new file mode 100644 index 00000000000..9dc88fd936f --- /dev/null +++ b/packages/grafana-ui/src/utils/useClientRect.ts @@ -0,0 +1,11 @@ +import { useState, useCallback } from 'react'; + +export const useClientRect = (): [{ width: number; height: number } | null, React.Ref] => { + const [rect, setRect] = useState<{ width: number; height: number } | null>(null); + const ref = useCallback((node: T) => { + if (node !== null) { + setRect(node.getBoundingClientRect()); + } + }, []); + return [rect, ref]; +};