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 ( -
-
- Organization profile - -
-
+ <> +
+ Edit user + + setName(e.currentTarget.value)} + size="md" + /> + + + setEmail(e.currentTarget.value)} + size="md" + /> + + + setUsername(e.currentTarget.value)} + size="md" + /> + + + setDisabledUser(checked)} /> + + +
+
+ Change password + + setPassword(e.currentTarget.value)} + size="md" + prefix={} + /> + + +
+ +
+
+ CERT validation + + setPassword(e.currentTarget.value)} + size="lg" + /> + +
+ +
+ ); -}); +}; 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()} + + ); +};