diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonDot.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonDot.tsx new file mode 100644 index 00000000000..5b594812f0d --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonDot.tsx @@ -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 ( + + ); +}; + +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)}; + `, +}); diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.mdx b/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.mdx new file mode 100644 index 00000000000..6e98a7e4ab3 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.mdx @@ -0,0 +1,72 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { RadioButtonList } from './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'; + + + +``` + +### 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']; + + + +``` + +### 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'; + + + +``` + + diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.story.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.story.tsx new file mode 100644 index 00000000000..0a647519db7 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.story.tsx @@ -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> = [ + { 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; + +const longTextOptions: Array> = [ + { + 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 = ({ disabled, disabledOptions }) => ( +
+ +
+); + +export const LongLabels: ComponentStory = ({ disabled, disabledOptions }) => ( +
+ +
+); + +export const ControlledComponent: Story> = ({ disabled, disabledOptions }) => { + const [selected, setSelected] = useState(defaultOptions[0].value!); + + return ( +
+ +
+ ); +}; + +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 }) => ( +
+
{title}
+ {children} +
+ ); + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +}; +Dots.parameters = { + controls: { + hideNoControlsWarning: true, + }, +}; diff --git a/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.tsx b/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.tsx new file mode 100644 index 00000000000..87e709db2d0 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/RadioButtonList/RadioButtonList.tsx @@ -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 { + /** 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>; + 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({ + name, + id, + options, + value, + onChange, + className, + disabled, + disabledOptions = [], +}: RadioButtonListProps) { + const styles = useStyles2(getStyles); + const internalId = id ?? uniqueId('radiogroup-list-'); + + return ( +
+ {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 ( + + ); + })} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: grid; + gap: ${theme.spacing(1)}; + `, +}); diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index e4208a61655..4c32405d854 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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';