Commit 1bd0c87f by Dominik Prokop Committed by GitHub

Forms: Introduce form field (#20632)

* Introduce new Switch component

* Experiment with different focus style

* Review update

* Update on/off swtch colors

* Introduce Form.Field component

* Enable className prop on form's field

* Remove not used imports

* Update packages/grafana-ui/src/components/Forms/Field.tsx

Co-Authored-By: Peter Holmberg <peterholmberg@users.noreply.github.com>

* Make switch usable in field story

* Add predefined input sizes

* Add util to display story on a debug canvas

* Test form

* Updated the test form

* Fix snapshot
parent e7f0bbf1
......@@ -79,6 +79,7 @@ export interface GrafanaThemeCommons {
formLabelPadding: string;
formLabelMargin: string;
formValidationMessagePadding: string;
formValidationMessageMargin: string;
};
border: {
radius: {
......
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { Field } from './Field';
<Meta title="MDX|Field" component={Field} />
# Field
`Field` is the basic component for rendering form elements together with labels and description
### Usage
```jsx
import { Forms } from '@grafana/ui';
<Forms.Field label={...} description={...}>
<Forms.Input id="userName" onChange={...}/>
</Forms.Field>
```
### Props
<Props of={Field} />
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 (
<div style={{ width: containerWidth }}>
<Field label="Graphite API key" description="Your Graphite instance API key" {...otherProps}>
<Input id="thisField" />
</Field>
</div>
);
};
export const horizontalLayout = () => {
const [checked, setChecked] = useState(false);
const { containerWidth, ...otherProps } = getKnobs();
return (
<div style={{ width: containerWidth }}>
<Field horizontal label="Show labels" description="Display thresholds's labels" {...otherProps}>
<Switch
checked={checked}
onChange={(e, checked) => {
setChecked(checked);
}}
/>
</Field>
</div>
);
};
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<FieldProps> = ({
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 (
<div className={cx(styles.field, horizontal && styles.fieldHorizontal, className)}>
{label && (
<Label htmlFor={inputId} description={description}>
{label}
</Label>
)}
<div>
{React.cloneElement(children, { invalid, disabled, loading })}
{error && !horizontal && (
<div className={styles.fieldValidationWrapper}>
<FieldValidationMessage>{error}</FieldValidationMessage>
</div>
)}
</div>
{error && horizontal && (
<div className={cx(styles.fieldValidationWrapper, styles.fieldValidationWrapperHorizontal)}>
<FieldValidationMessage>{error}</FieldValidationMessage>
</div>
)}
</div>
);
};
......@@ -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: '';
......
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 (
<div>
<fieldset>
<Legend>Organization profile</Legend>
<Label description="Provide a name of your organisation that will be used across Grafana installation">
Organization name
</Label>
</fieldset>
</div>
<>
<Form>
<Legend>Edit user</Legend>
<Field label="Name">
<Input
id="name"
placeholder="Roger Waters"
value={name}
onChange={e => setName(e.currentTarget.value)}
size="md"
/>
</Field>
<Field label="Email">
<Input
id="email"
type="email"
placeholder="roger.waters@grafana.com"
value={email}
onChange={e => setEmail(e.currentTarget.value)}
size="md"
/>
</Field>
<Field label="Username">
<Input
id="username"
placeholder="mr.waters"
value={username}
onChange={e => setUsername(e.currentTarget.value)}
size="md"
/>
</Field>
<Field label="Disable" description="Added for testing purposes">
<Switch checked={disabledUser} onChange={(_e, checked) => setDisabledUser(checked)} />
</Field>
<Button>Update</Button>
</Form>
<Form>
<Legend>Change password</Legend>
<Field label="Password">
<Input
id="password>"
type="password"
placeholder="Be safe..."
value={password}
onChange={e => setPassword(e.currentTarget.value)}
size="md"
prefix={<Icon name="lock" />}
/>
</Field>
<Button>Update</Button>
</Form>
<Form>
<fieldset>
<Legend>CERT validation</Legend>
<Field
label="Path to client cert"
description="Authentication against LDAP servers requiring client certificates if not required leave empty "
>
<Input
id="clientCert"
value={''}
// onChange={e => setPassword(e.currentTarget.value)}
size="lg"
/>
</Field>
</fieldset>
<Button>Update</Button>
</Form>
</>
);
});
};
/**
* 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 <div className={styles.form}>{children}</div>;
};
......@@ -6,7 +6,9 @@ import { stylesFactory, useTheme } from '../../../themes';
import { Icon } from '../../Icon/Icon';
import { useClientRect } from '../../../utils/useClientRect';
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix'> {
export type FormInputSize = 'sm' | 'md' | 'lg' | 'auto';
export interface Props extends Omit<HTMLProps<HTMLInputElement>, '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<HTMLProps<HTMLInputElement>, '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> = 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> = props => {
const styles = getInputStyles({ theme, invalid: !!invalid });
return (
<div className={styles.wrapper}>
<div className={cx(styles.wrapper, styles.inputSize[size])}>
{!!addonBefore && <div className={styles.addon}>{addonBefore}</div>}
<div className={styles.inputWrapper}>
......
......@@ -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<HTMLLabelElement> {
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
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};
......
......@@ -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: {
......
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 (
<div
className={cx(
css`
width: ${finalWidth};
`,
bgStyles
)}
>
{children}
</div>
);
};
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 (
<StoryContainer width={fullWidthContainter ? undefined : containerWidth} showBoundaries={containerBoundary}>
{story()}
</StoryContainer>
);
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment