Radio: New Radio Button List component (#49052)

* Add RadioButtonDot component

* Add basic RadioGroupList component

* Add RadioButtonList component, improve disabled styles

* Refactor storybook

* Add docs, export to @grafana/ui

* Improve docs, add option descriptions

* Double spacing between elements

* Improve storybook
This commit is contained in:
Konrad Lalik
2022-05-23 15:59:33 +02:00
committed by GitHub
parent 3dfafbadef
commit 0215195e6d
5 changed files with 381 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes';
export interface RadioButtonDotProps {
id: string;
name: string;
checked?: boolean;
disabled?: boolean;
label: React.ReactNode;
description?: string;
onChange?: (id: string) => void;
}
export const RadioButtonDot = ({ id, name, label, checked, disabled, description, onChange }: RadioButtonDotProps) => {
const styles = useStyles2(getStyles);
return (
<label title={description} className={styles.label}>
<input
id={id}
name={name}
type="radio"
checked={checked}
disabled={disabled}
className={styles.input}
onChange={() => onChange && onChange(id)}
/>
{label}
</label>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
input: css`
position: relative;
appearance: none;
outline: none;
background-color: ${theme.colors.background.canvas};
margin: 0;
width: ${theme.spacing(2)} !important; /* TODO How to overcome this? Checkbox does the same 🙁 */
height: ${theme.spacing(2)};
border: 1px solid ${theme.colors.border.medium};
border-radius: 50%;
margin: 3px 0; /* Space for box-shadow when focused */
:checked {
background-color: ${theme.v1.palette.white};
border: 5px solid ${theme.colors.primary.main};
}
:disabled {
background-color: ${theme.colors.action.disabledBackground} !important;
border-color: ${theme.colors.border.weak};
}
:disabled:checked {
border: 1px solid ${theme.colors.border.weak};
}
:disabled:checked::after {
content: '';
width: 6px;
height: 6px;
background-color: ${theme.colors.text.disabled};
border-radius: 50%;
display: inline-block;
position: absolute;
top: 4px;
left: 4px;
}
:focus {
outline: none !important;
box-shadow: 0 0 0 1px ${theme.colors.background.canvas}, 0 0 0 3px ${theme.colors.primary.main};
}
`,
label: css`
font-size: ${theme.typography.fontSize};
line-height: 22px; /* 16px for the radio button and 6px for the focus shadow */
display: grid;
grid-template-columns: ${theme.spacing(2)} auto;
gap: ${theme.spacing(1)};
`,
});

View File

@@ -0,0 +1,72 @@
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { RadioButtonList } from './RadioButtonList';
<Meta title="MDX|RadioButtonList" component={RadioButtonList} />
# RadioButtonList
`RadioButtonList` is used to select a single value from multiple mutually exclusive options usually in a vertical manner.
## When to use
Use `RadioButtonList` for mutually exclusive selections.
Contrary to the [RadioButtonGroup](?path=/docs/forms-radiobuttongroup--radio-buttons) component, `RadioButtonList` can contain more than four options because by default it lays out the items vertically.
This component should be used instead of [Select](?path=/docs/forms-select--basic) when there is a need for the user to see all of the options available without clicking and scrolling the dropdown.
## Usage
### Basic radio group
```jsx
import { RadioButtonList } from '@grafana/ui';
<RadioButtonGroup options={...} value={...} onChange={...} />
```
### Disabled options
You can disable some options by passing them to the `disabledOptions` prop.
Keep in mind the `disabledOptions` are compared with options' values by the `===` operator.
```jsx
import { RadioButtonList } from '@grafana/ui';
const options = [
{ label: 'Prometheus', value: 'prometheus' },
{ label: 'Graphite', value: 'graphite' },
{ label: 'Elastic', value: 'elastic' },
{ label: 'InfluxDB', value: 'influx' },
];
const disabledOptions = ['prometheus', 'elastic'];
<RadioButtonGroup
options={options}
disabledOptions={disabledOptions}
value={...}
onChange={...}
/>
```
### Changing layout
The `RadioButtonList` layout uses CSS Grid, so it is effortless to split the list into multiple columns
```jsx
import { RadioButtonList } from '@grafana/ui';
<RadioButtonGroup
options={...}
value={...}
onChange={...}
className={css`
grid-template-columns: 1fr 1fr 1fr;
`}
/>
```
<Props of={RadioButtonList} />

View File

@@ -0,0 +1,149 @@
import { ComponentMeta, ComponentStory, Story } from '@storybook/react';
import React, { useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { RadioButtonDot } from './RadioButtonDot';
import { RadioButtonList, RadioButtonListProps } from './RadioButtonList';
import mdx from './RadioButtonList.mdx';
const defaultOptions: Array<SelectableValue<string>> = [
{ label: 'Option 1', value: 'opt-1', description: 'A description of Option 1' },
{ label: 'Option 2', value: 'opt-2', description: 'A description of Option 2' },
{ label: 'Option 3', value: 'opt-3', description: 'A description of Option 3' },
{ label: 'Option 4', value: 'opt-4', description: 'A description of Option 4' },
{ label: 'Option 5', value: 'opt-5', description: 'A description of Option 5' },
];
export default {
title: 'Forms/RadioButtonList',
component: RadioButtonList,
parameters: {
controls: {
exclude: ['name', 'id', 'keySelector', 'onChange', 'className', 'value'],
},
docs: {
page: mdx,
},
},
argTypes: {
value: {
options: defaultOptions.map((x) => x.value!),
},
disabledOptions: {
control: 'multi-select',
options: defaultOptions.map((x) => x.value!),
},
},
args: {
options: defaultOptions,
disabled: false,
},
} as ComponentMeta<typeof RadioButtonList>;
const longTextOptions: Array<SelectableValue<string>> = [
{
value: 'opt-1',
label:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua',
},
{
value: 'opt-2',
label:
'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
},
{
value: 'opt-3',
label:
'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum',
},
{
value: 'opt-4',
label:
'Nulla posuere sollicitudin aliquam ultrices sagittis orci a scelerisque purus. Congue quisque egestas diam in. Sit amet mattis vulputate enim nulla aliquet porttitor lacus. Augue lacus viverra vitae congue eu consequat ac.',
},
{
value: 'opt-5',
label:
'Aliquam malesuada bibendum arcu vitae elementum curabitur vitae nunc sed. Elit eget gravida cum sociis natoque penatibus et magnis dis. Varius sit amet mattis vulputate. Et ultrices neque ornare aenean euismod elementum nisi quis eleifend.',
},
];
export const Default: ComponentStory<typeof RadioButtonList> = ({ disabled, disabledOptions }) => (
<div>
<RadioButtonList name="default" options={defaultOptions} disabled={disabled} disabledOptions={disabledOptions} />
</div>
);
export const LongLabels: ComponentStory<typeof RadioButtonList> = ({ disabled, disabledOptions }) => (
<div>
<RadioButtonList name="default" options={longTextOptions} disabled={disabled} disabledOptions={disabledOptions} />
</div>
);
export const ControlledComponent: Story<RadioButtonListProps<string>> = ({ disabled, disabledOptions }) => {
const [selected, setSelected] = useState<string>(defaultOptions[0].value!);
return (
<div>
<RadioButtonList
name="default"
options={defaultOptions}
value={selected}
onChange={setSelected}
disabled={disabled}
disabledOptions={disabledOptions}
/>
</div>
);
};
export const DisabledOptions = Default.bind({});
DisabledOptions.args = {
disabledOptions: ['opt-4', 'opt-5'],
};
export const DisabledCheckedOption = ControlledComponent.bind({});
DisabledCheckedOption.args = {
value: 'opt-2',
disabledOptions: ['opt-1', 'opt-2', 'opt-3'],
};
export const DisabledList = Default.bind({});
DisabledList.args = {
disabled: true,
};
export const Dots: Story = () => {
const Wrapper: React.FC<{ title: string }> = ({ title, children }) => (
<div style={{ marginBottom: 20 }}>
<h5>{title}</h5>
{children}
</div>
);
return (
<div>
<Wrapper title="Default">
<RadioButtonDot id="1" name="default-empty" label="Radio label" checked={false} />
</Wrapper>
<Wrapper title="Checked">
<RadioButtonDot id="2" name="default-checked" label="Radio label" checked />
</Wrapper>
<Wrapper title="Disabled default">
<RadioButtonDot id="3" name="disabled-default-empty" label="Radio label" disabled />
</Wrapper>
<Wrapper title="Disabled checked">
<RadioButtonDot id="4" name="disabled-default-checked" label="Radio label" checked disabled />
</Wrapper>
</div>
);
};
Dots.parameters = {
controls: {
hideNoControlsWarning: true,
},
};

View File

@@ -0,0 +1,71 @@
import { css, cx } from '@emotion/css';
import { uniqueId } from 'lodash';
import React from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { useStyles2 } from '../../../themes';
import { RadioButtonDot } from './RadioButtonDot';
export interface RadioButtonListProps<T> {
/** A name of a radio group. Used to group multiple radio inputs into a single group */
name: string;
id?: string;
/** An array of available options */
options: Array<SelectableValue<T>>;
value?: T;
onChange?: (value: T) => void;
/** Disables all elements in the list */
disabled?: boolean;
/** Disables subset of elements in the list. Compares values using the === operator */
disabledOptions?: T[];
className?: string;
}
export function RadioButtonList<T>({
name,
id,
options,
value,
onChange,
className,
disabled,
disabledOptions = [],
}: RadioButtonListProps<T>) {
const styles = useStyles2(getStyles);
const internalId = id ?? uniqueId('radiogroup-list-');
return (
<div id={id} className={cx(styles.container, className)} role="radiogroup">
{options.map((option, index) => {
const itemId = `${internalId}-${index}`;
const isChecked = value && value === option.value;
const isDisabled = disabled || disabledOptions.some((optionValue) => optionValue === option.value);
const handleChange = () => onChange && option.value && onChange(option.value);
return (
<RadioButtonDot
key={itemId}
id={itemId}
name={name}
label={option.label}
description={option.description}
checked={isChecked}
disabled={isDisabled}
onChange={handleChange}
/>
);
})}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
display: grid;
gap: ${theme.spacing(1)};
`,
});

View File

@@ -215,6 +215,7 @@ export * from './Select/types';
export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout';
export { Badge, BadgeColor, BadgeProps } from './Badge/Badge';
export { RadioButtonGroup } from './Forms/RadioButtonGroup/RadioButtonGroup';
export { RadioButtonList } from './Forms/RadioButtonList/RadioButtonList';
export { Input, getInputStyles } from './Input/Input';
export { FilterInput } from './FilterInput/FilterInput';