mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
e7f0bbf1ff
commit
1bd0c87f66
packages
grafana-data/src/types
grafana-ui/src
components/Forms
themes
utils/storybook
@ -79,6 +79,7 @@ export interface GrafanaThemeCommons {
|
||||
formLabelPadding: string;
|
||||
formLabelMargin: string;
|
||||
formValidationMessagePadding: string;
|
||||
formValidationMessageMargin: string;
|
||||
};
|
||||
border: {
|
||||
radius: {
|
||||
|
22
packages/grafana-ui/src/components/Forms/Field.mdx
Normal file
22
packages/grafana-ui/src/components/Forms/Field.mdx
Normal file
@ -0,0 +1,22 @@
|
||||
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} />
|
||||
|
68
packages/grafana-ui/src/components/Forms/Field.story.tsx
Normal file
68
packages/grafana-ui/src/components/Forms/Field.story.tsx
Normal file
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
95
packages/grafana-ui/src/components/Forms/Field.tsx
Normal file
95
packages/grafana-ui/src/components/Forms/Field.tsx
Normal file
@ -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<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: '';
|
||||
|
@ -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 (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
22
packages/grafana-ui/src/components/Forms/Form.tsx
Normal file
22
packages/grafana-ui/src/components/Forms/Form.tsx
Normal file
@ -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 <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: {
|
||||
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user