diff --git a/packages/grafana-data/src/types/theme.ts b/packages/grafana-data/src/types/theme.ts
index fced5a9b0c5..6f3ad8ff742 100644
--- a/packages/grafana-data/src/types/theme.ts
+++ b/packages/grafana-data/src/types/theme.ts
@@ -79,6 +79,7 @@ export interface GrafanaThemeCommons {
formLabelPadding: string;
formLabelMargin: string;
formValidationMessagePadding: string;
+ formValidationMessageMargin: string;
};
border: {
radius: {
diff --git a/packages/grafana-ui/src/components/Forms/Field.mdx b/packages/grafana-ui/src/components/Forms/Field.mdx
new file mode 100644
index 00000000000..c34113368b7
--- /dev/null
+++ b/packages/grafana-ui/src/components/Forms/Field.mdx
@@ -0,0 +1,22 @@
+import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
+import { Field } from './Field';
+
+
+
+# Field
+
+`Field` is the basic component for rendering form elements together with labels and description
+
+### Usage
+
+```jsx
+import { Forms } from '@grafana/ui';
+
+
+
+
+```
+
+### Props
+
+
diff --git a/packages/grafana-ui/src/components/Forms/Field.story.tsx b/packages/grafana-ui/src/components/Forms/Field.story.tsx
new file mode 100644
index 00000000000..addd24e918d
--- /dev/null
+++ b/packages/grafana-ui/src/components/Forms/Field.story.tsx
@@ -0,0 +1,68 @@
+import React, { useState } from 'react';
+import { boolean, number, text } from '@storybook/addon-knobs';
+import { Field } from './Field';
+import { Input } from './Input/Input';
+import { Switch } from './Switch';
+import mdx from './Field.mdx';
+
+export default {
+ title: 'UI/Forms/Field',
+ component: Field,
+ parameters: {
+ docs: {
+ page: mdx,
+ },
+ },
+};
+
+const getKnobs = () => {
+ const CONTAINER_GROUP = 'Container options';
+ // ---
+ const containerWidth = number(
+ 'Container width',
+ 300,
+ {
+ range: true,
+ min: 100,
+ max: 500,
+ step: 10,
+ },
+ CONTAINER_GROUP
+ );
+
+ const BEHAVIOUR_GROUP = 'Behaviour props';
+ const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP);
+ const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP);
+ const loading = boolean('Loading', false, BEHAVIOUR_GROUP);
+ const error = text('Error message', '', BEHAVIOUR_GROUP);
+
+ return { containerWidth, disabled, invalid, loading, error };
+};
+
+export const simple = () => {
+ const { containerWidth, ...otherProps } = getKnobs();
+ return (
+
+
+
+
+
+ );
+};
+
+export const horizontalLayout = () => {
+ const [checked, setChecked] = useState(false);
+ const { containerWidth, ...otherProps } = getKnobs();
+ return (
+
+
+ {
+ setChecked(checked);
+ }}
+ />
+
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/Forms/Field.tsx b/packages/grafana-ui/src/components/Forms/Field.tsx
new file mode 100644
index 00000000000..691a4531046
--- /dev/null
+++ b/packages/grafana-ui/src/components/Forms/Field.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import { Label } from './Label';
+import { stylesFactory, useTheme } from '../../themes';
+import { css, cx } from 'emotion';
+import { GrafanaTheme } from '@grafana/data';
+import { FieldValidationMessage } from './FieldValidationMessage';
+
+export interface FieldProps {
+ /** Form input element, i.e Input or Switch */
+ children: React.ReactElement;
+ /** Label for the field */
+ label?: string;
+ /** Description of the field */
+ description?: string;
+ /** Indicates if field is in invalid state */
+ invalid?: boolean;
+ /** Indicates if field is in loading state */
+ loading?: boolean;
+ /** Indicates if field is disabled */
+ disabled?: boolean;
+ /** Error message to display */
+ error?: string;
+ /** Indicates horizontal layout of the field */
+ horizontal?: boolean;
+ className?: string;
+}
+
+export const getFieldStyles = stylesFactory((theme: GrafanaTheme) => {
+ return {
+ field: css`
+ display: flex;
+ flex-direction: column;
+ margin-bottom: ${theme.spacing.formSpacingBase * 2}px;
+ `,
+ fieldHorizontal: css`
+ flex-direction: row;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ `,
+ fieldValidationWrapper: css`
+ margin-top: ${theme.spacing.formSpacingBase / 2}px;
+ `,
+ fieldValidationWrapperHorizontal: css`
+ flex: 1 1 100%;
+ `,
+ };
+});
+
+export const Field: React.FC = ({
+ label,
+ description,
+ horizontal,
+ invalid,
+ loading,
+ disabled,
+ error,
+ children,
+ className,
+}) => {
+ const theme = useTheme();
+ let inputId;
+ const styles = getFieldStyles(theme);
+
+ // Get the first, and only, child to retrieve form input's id
+ const child = React.Children.map(children, c => c)[0];
+
+ if (child) {
+ // Retrieve input's id to apply on the label for correct click interaction
+ inputId = (child as React.ReactElement<{ id?: string }>).props.id;
+ }
+
+ return (
+
+ {label && (
+
+ )}
+
+ {React.cloneElement(children, { invalid, disabled, loading })}
+ {error && !horizontal && (
+
+ {error}
+
+ )}
+
+
+ {error && horizontal && (
+
+ {error}
+
+ )}
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx b/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx
index 4b565aa8df0..f1c57a41651 100644
--- a/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx
+++ b/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx
@@ -13,12 +13,13 @@ export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaThem
fieldValidationMessage: css`
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
- margin: ${theme.spacing.formLabelMargin};
+ margin: ${theme.spacing.formValidationMessageMargin};
padding: ${theme.spacing.formValidationMessagePadding};
color: ${theme.colors.formValidationMessageText};
background: ${theme.colors.formValidationMessageBg};
border-radius: ${theme.border.radius.sm};
position: relative;
+ display: inline-block;
&:before {
content: '';
diff --git a/packages/grafana-ui/src/components/Forms/Form.story.tsx b/packages/grafana-ui/src/components/Forms/Form.story.tsx
index a9e374055cd..38f2ef6b6b9 100644
--- a/packages/grafana-ui/src/components/Forms/Form.story.tsx
+++ b/packages/grafana-ui/src/components/Forms/Form.story.tsx
@@ -1,20 +1,97 @@
-import React from 'react';
-import { storiesOf } from '@storybook/react';
-
+import React, { useState } from 'react';
import { Legend } from './Legend';
-import { Label } from './Label';
-const story = storiesOf('UI/Forms/Test', module);
+import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { withStoryContainer } from '../../utils/storybook/withStoryContainer';
+import { Field } from './Field';
+import { Input } from './Input/Input';
+import { Button } from './Button';
+import { Form } from './Form';
+import { Switch } from './Switch';
+import { Icon } from '../Icon/Icon';
+
+export default {
+ title: 'UI/Forms/Test forms/Server admin',
+ decorators: [withStoryContainer, withCenteredStory],
+};
+
+export const users = () => {
+ const [name, setName] = useState();
+ const [email, setEmail] = useState();
+ const [username, setUsername] = useState();
+ const [password, setPassword] = useState();
+ const [disabledUser, setDisabledUser] = useState(false);
-story.add('Configuration/Preferences', () => {
return (
-
-
-
+ <>
+
+
+
+
+ >
);
-});
+};
diff --git a/packages/grafana-ui/src/components/Forms/Form.tsx b/packages/grafana-ui/src/components/Forms/Form.tsx
new file mode 100644
index 00000000000..0fcb59b20ac
--- /dev/null
+++ b/packages/grafana-ui/src/components/Forms/Form.tsx
@@ -0,0 +1,22 @@
+/**
+ * This is a stub implementation only for correct styles to be applied
+ */
+
+import React from 'react';
+import { stylesFactory, useTheme } from '../../themes';
+import { GrafanaTheme } from '@grafana/data';
+import { css } from 'emotion';
+
+const getFormStyles = stylesFactory((theme: GrafanaTheme) => {
+ return {
+ form: css`
+ margin-bottom: ${theme.spacing.formMargin};
+ `,
+ };
+});
+
+export const Form: React.FC = ({ children }) => {
+ const theme = useTheme();
+ const styles = getFormStyles(theme);
+ return {children}
;
+};
diff --git a/packages/grafana-ui/src/components/Forms/Input/Input.tsx b/packages/grafana-ui/src/components/Forms/Input/Input.tsx
index 61497c8c64b..c08ecbe99d5 100644
--- a/packages/grafana-ui/src/components/Forms/Input/Input.tsx
+++ b/packages/grafana-ui/src/components/Forms/Input/Input.tsx
@@ -6,7 +6,9 @@ import { stylesFactory, useTheme } from '../../../themes';
import { Icon } from '../../Icon/Icon';
import { useClientRect } from '../../../utils/useClientRect';
-export interface Props extends Omit, 'prefix'> {
+export type FormInputSize = 'sm' | 'md' | 'lg' | 'auto';
+
+export interface Props extends Omit, 'prefix' | 'size'> {
/** Show an invalid state around the input */
invalid?: boolean;
/** Show an icon as a prefix in the input */
@@ -17,6 +19,7 @@ export interface Props extends Omit, 'prefix'> {
addonBefore?: ReactNode;
/** Add a component as an addon after the input */
addonAfter?: ReactNode;
+ size?: FormInputSize;
}
interface StyleDeps {
@@ -55,7 +58,6 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
width: 100%;
height: ${height};
border-radius: ${borderRadius};
- margin-bottom: ${invalid ? theme.spacing.formSpacingBase / 2 : theme.spacing.formSpacingBase * 2}px;
&:hover {
> .prefix,
.suffix,
@@ -206,11 +208,25 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
right: 0;
`
),
+ inputSize: {
+ sm: css`
+ width: 200px;
+ `,
+ md: css`
+ width: 320px;
+ `,
+ lg: css`
+ width: 580px;
+ `,
+ auto: css`
+ width: 100%;
+ `,
+ },
};
});
export const Input: FC = props => {
- const { addonAfter, addonBefore, prefix, invalid, loading, ...restProps } = props;
+ const { addonAfter, addonBefore, prefix, invalid, loading, size = 'auto', ...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)).
@@ -223,7 +239,7 @@ export const Input: FC = props => {
const styles = getInputStyles({ theme, invalid: !!invalid });
return (
-
+
{!!addonBefore &&
{addonBefore}
}
diff --git a/packages/grafana-ui/src/components/Forms/Label.tsx b/packages/grafana-ui/src/components/Forms/Label.tsx
index 1e28317bbfb..ebeb48ec30c 100644
--- a/packages/grafana-ui/src/components/Forms/Label.tsx
+++ b/packages/grafana-ui/src/components/Forms/Label.tsx
@@ -3,7 +3,7 @@ import { useTheme, stylesFactory } from '../../themes';
import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion';
-export interface LabelProps extends React.HTMLAttributes
{
+export interface LabelProps extends React.LabelHTMLAttributes {
children: string;
description?: string;
}
@@ -13,9 +13,11 @@ export const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
label: css`
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
+ line-height: 1.25;
margin: ${theme.spacing.formLabelMargin};
padding: ${theme.spacing.formLabelPadding};
color: ${theme.colors.formLabel};
+ max-width: 480px;
`,
description: css`
font-weight: ${theme.typography.weight.regular};
diff --git a/packages/grafana-ui/src/themes/default.ts b/packages/grafana-ui/src/themes/default.ts
index 66e43489682..602456dba65 100644
--- a/packages/grafana-ui/src/themes/default.ts
+++ b/packages/grafana-ui/src/themes/default.ts
@@ -99,6 +99,7 @@ const theme: GrafanaThemeCommons = {
formLabelPadding: '0 0 0 2px',
formLabelMargin: `0 0 ${SPACING_BASE / 2 + 'px'} 0`,
formValidationMessagePadding: '4px 8px',
+ formValidationMessageMargin: '4px 0 0 0',
},
border: {
radius: {
diff --git a/packages/grafana-ui/src/utils/storybook/withStoryContainer.tsx b/packages/grafana-ui/src/utils/storybook/withStoryContainer.tsx
new file mode 100644
index 00000000000..9b6b5ddd7dd
--- /dev/null
+++ b/packages/grafana-ui/src/utils/storybook/withStoryContainer.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { RenderFunction } from '@storybook/react';
+import { boolean, number } from '@storybook/addon-knobs';
+import { css, cx } from 'emotion';
+
+const StoryContainer: React.FC<{ width?: number; showBoundaries: boolean }> = ({ children, width, showBoundaries }) => {
+ const checkColor = '#f0f0f0';
+ const finalWidth = width ? `${width}px` : '100%';
+ const bgStyles =
+ showBoundaries &&
+ css`
+ background-color: white;
+ background-size: 30px 30px;
+ background-position: 0 0, 15px 15px;
+ background-image: linear-gradient(
+ 45deg,
+ ${checkColor} 25%,
+ transparent 25%,
+ transparent 75%,
+ ${checkColor} 75%,
+ ${checkColor}
+ ),
+ linear-gradient(45deg, ${checkColor} 25%, transparent 25%, transparent 75%, ${checkColor} 75%, ${checkColor});
+ `;
+ return (
+
+ {children}
+
+ );
+};
+
+export const withStoryContainer = (story: RenderFunction) => {
+ const CONTAINER_GROUP = 'Container options';
+ // ---
+ const containerBoundary = boolean('Show container boundary', false, CONTAINER_GROUP);
+ const fullWidthContainter = boolean('Full width container', false, CONTAINER_GROUP);
+ const containerWidth = number(
+ 'Container width',
+ 300,
+ {
+ range: true,
+ min: 100,
+ max: 500,
+ step: 10,
+ },
+ CONTAINER_GROUP
+ );
+ return (
+
+ {story()}
+
+ );
+};