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];
+};