diff options
Diffstat (limited to 'src/components/molecules/forms')
29 files changed, 1683 insertions, 0 deletions
diff --git a/src/components/molecules/forms/ackee-select.module.scss b/src/components/molecules/forms/ackee-select.module.scss new file mode 100644 index 0000000..87cd9ee --- /dev/null +++ b/src/components/molecules/forms/ackee-select.module.scss @@ -0,0 +1,11 @@ +.wrapper { + display: flex; + flex-flow: row wrap; + align-items: center; + position: relative; +} + +.tooltip { + position: absolute; + bottom: -100%; +} diff --git a/src/components/molecules/forms/ackee-select.stories.tsx b/src/components/molecules/forms/ackee-select.stories.tsx new file mode 100644 index 0000000..a59bfa9 --- /dev/null +++ b/src/components/molecules/forms/ackee-select.stories.tsx @@ -0,0 +1,32 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import AckeeSelectComponent from './ackee-select'; + +export default { + title: 'Molecules/Forms', + component: AckeeSelectComponent, + argTypes: { + initialValue: { + control: { + type: 'select', + }, + description: 'Initial selected option.', + options: ['full', 'partial'], + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof AckeeSelectComponent>; + +const Template: ComponentStory<typeof AckeeSelectComponent> = (args) => ( + <IntlProvider locale="en"> + <AckeeSelectComponent {...args} /> + </IntlProvider> +); + +export const AckeeSelect = Template.bind({}); +AckeeSelect.args = { + initialValue: 'full', +}; diff --git a/src/components/molecules/forms/ackee-select.test.tsx b/src/components/molecules/forms/ackee-select.test.tsx new file mode 100644 index 0000000..e1e6b2d --- /dev/null +++ b/src/components/molecules/forms/ackee-select.test.tsx @@ -0,0 +1,23 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@test-utils'; +import AckeeSelect from './ackee-select'; + +describe('Select', () => { + it('should correctly set default option', () => { + render(<AckeeSelect initialValue="full" />); + expect(screen.getByRole('combobox')).toHaveValue('full'); + expect(screen.queryByRole('combobox')).not.toHaveValue('partial'); + }); + + it('should correctly change value when user choose another option', () => { + render(<AckeeSelect initialValue="full" />); + + userEvent.selectOptions( + screen.getByRole('combobox'), + screen.getByRole('option', { name: 'Partial' }) + ); + + expect(screen.getByRole('combobox')).toHaveValue('partial'); + expect(screen.queryByRole('combobox')).not.toHaveValue('full'); + }); +}); diff --git a/src/components/molecules/forms/ackee-select.tsx b/src/components/molecules/forms/ackee-select.tsx new file mode 100644 index 0000000..4a8410c --- /dev/null +++ b/src/components/molecules/forms/ackee-select.tsx @@ -0,0 +1,89 @@ +import { SelectOptions } from '@components/atoms/forms/select'; +import { Dispatch, SetStateAction, useState, VFC } from 'react'; +import { useIntl } from 'react-intl'; +import SelectWithTooltip, { + SelectWithTooltipProps, +} from './select-with-tooltip'; + +export type AckeeOptions = 'full' | 'partial'; + +export type AckeeSelectProps = Pick< + SelectWithTooltipProps, + 'labelClassName' | 'tooltipClassName' +> & { + /** + * A default value for Ackee settings. + */ + initialValue: AckeeOptions; +}; + +/** + * AckeeSelect component + * + * Render a select to set Ackee settings. + */ +const AckeeSelect: VFC<AckeeSelectProps> = ({ initialValue, ...props }) => { + const intl = useIntl(); + const [value, setValue] = useState<AckeeOptions>(initialValue); + + const ackeeLabel = intl.formatMessage({ + defaultMessage: 'Tracking:', + description: 'AckeeSelect: select label', + id: '2pmylc', + }); + const tooltipTitle = intl.formatMessage({ + defaultMessage: 'Ackee tracking (analytics)', + description: 'AckeeSelect: tooltip title', + id: 'F1EQX3', + }); + const tooltipContent = [ + intl.formatMessage({ + defaultMessage: 'Partial includes only page url, views and duration.', + description: 'AckeeSelect: tooltip message', + id: 'skb4W5', + }), + intl.formatMessage({ + defaultMessage: + 'Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.', + description: 'AckeeSelect: tooltip message', + id: 'Ogccx6', + }), + ]; + const options: SelectOptions[] = [ + { + id: 'partial', + name: intl.formatMessage({ + defaultMessage: 'Partial', + description: 'AckeeSelect: partial option name', + id: 'e/8Kyj', + }), + value: 'partial', + }, + { + id: 'full', + name: intl.formatMessage({ + defaultMessage: 'Full', + description: 'AckeeSelect: full option name', + id: 'PzRpPw', + }), + value: 'full', + }, + ]; + + return ( + <SelectWithTooltip + id="ackee-settings" + name="ackee-settings" + label={ackeeLabel} + labelSize="medium" + options={options} + title={tooltipTitle} + content={tooltipContent} + value={value} + setValue={setValue as Dispatch<SetStateAction<string>>} + {...props} + /> + ); +}; + +export default AckeeSelect; diff --git a/src/components/molecules/forms/labelled-field.module.scss b/src/components/molecules/forms/labelled-field.module.scss new file mode 100644 index 0000000..64ef3d0 --- /dev/null +++ b/src/components/molecules/forms/labelled-field.module.scss @@ -0,0 +1,9 @@ +.label { + &--left { + margin-right: var(--spacing-2xs); + } + + &--top { + display: block; + } +} diff --git a/src/components/molecules/forms/labelled-field.stories.tsx b/src/components/molecules/forms/labelled-field.stories.tsx new file mode 100644 index 0000000..b77d71e --- /dev/null +++ b/src/components/molecules/forms/labelled-field.stories.tsx @@ -0,0 +1,201 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import LabelledFieldComponent from './labelled-field'; + +export default { + title: 'Molecules/Forms', + component: LabelledFieldComponent, + args: { + disabled: false, + labelPosition: 'top', + required: false, + }, + argTypes: { + disabled: { + control: { + type: 'boolean', + }, + description: 'Field state: either enabled or disabled.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'Field id.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'Field label.', + type: { + name: 'string', + required: true, + }, + }, + labelPosition: { + control: { + type: 'select', + }, + description: 'The label position.', + options: ['left', 'top'], + table: { + category: 'Options', + defaultValue: { summary: 'top' }, + }, + type: { + name: 'string', + required: false, + }, + }, + max: { + control: { + type: 'number', + }, + description: 'Maximum value.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + min: { + control: { + type: 'number', + }, + description: 'Minimum value.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'Field name.', + type: { + name: 'string', + required: true, + }, + }, + placeholder: { + control: { + type: 'text', + }, + description: 'A placeholder value.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + required: { + control: { + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + setValue: { + control: { + type: null, + }, + description: 'Callback function to set field value.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: true, + }, + }, + step: { + control: { + type: 'number', + }, + description: 'Field incremental values that are valid.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + type: { + control: { + type: 'select', + }, + description: 'Field type: input type or textarea.', + options: [ + 'datetime-local', + 'email', + 'number', + 'search', + 'tel', + 'text', + 'textarea', + 'time', + 'url', + ], + type: { + name: 'string', + required: true, + }, + }, + value: { + control: { + type: null, + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof LabelledFieldComponent>; + +const Template: ComponentStory<typeof LabelledFieldComponent> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [value, setValue] = useState<string>(''); + + return <LabelledFieldComponent value={value} setValue={setValue} {...args} />; +}; + +export const LabelledField = Template.bind({}); +LabelledField.args = { + id: 'labelled-field-storybook', + label: 'Labelled field', + name: 'labelled-field-storybook', +}; diff --git a/src/components/molecules/forms/labelled-field.test.tsx b/src/components/molecules/forms/labelled-field.test.tsx new file mode 100644 index 0000000..6fabe19 --- /dev/null +++ b/src/components/molecules/forms/labelled-field.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@test-utils'; +import LabelledField from './labelled-field'; + +describe('LabelledField', () => { + it('renders a labelled field', () => { + render( + <LabelledField + type="text" + id="jest-text-field" + name="jest-text-field" + label="Jest text field" + value="test" + setValue={() => null} + /> + ); + expect(screen.getByLabelText('Jest text field')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('test'); + }); +}); diff --git a/src/components/molecules/forms/labelled-field.tsx b/src/components/molecules/forms/labelled-field.tsx new file mode 100644 index 0000000..08d0126 --- /dev/null +++ b/src/components/molecules/forms/labelled-field.tsx @@ -0,0 +1,51 @@ +import Field, { type FieldProps } from '@components/atoms/forms/field'; +import Label from '@components/atoms/forms/label'; +import { VFC } from 'react'; +import styles from './labelled-field.module.scss'; + +export type LabelledFieldProps = FieldProps & { + /** + * Visually hide the field label. Default: false. + */ + hideLabel?: boolean; + /** + * The field label. + */ + label: string; + /** + * The label position. Default: top. + */ + labelPosition?: 'left' | 'top'; +}; + +/** + * LabelledField component + * + * Render a field tied to a label. + */ +const LabelledField: VFC<LabelledFieldProps> = ({ + hideLabel = false, + id, + label, + labelPosition = 'top', + required, + ...props +}) => { + const positionModifier = `label--${labelPosition}`; + const visibilityClass = hideLabel ? 'screen-reader-text' : ''; + + return ( + <> + <Label + htmlFor={id} + required={required} + className={`${visibilityClass} ${styles[positionModifier]}`} + > + {label} + </Label> + <Field id={id} required={required} {...props} /> + </> + ); +}; + +export default LabelledField; diff --git a/src/components/molecules/forms/labelled-select.module.scss b/src/components/molecules/forms/labelled-select.module.scss new file mode 100644 index 0000000..64ef3d0 --- /dev/null +++ b/src/components/molecules/forms/labelled-select.module.scss @@ -0,0 +1,9 @@ +.label { + &--left { + margin-right: var(--spacing-2xs); + } + + &--top { + display: block; + } +} diff --git a/src/components/molecules/forms/labelled-select.stories.tsx b/src/components/molecules/forms/labelled-select.stories.tsx new file mode 100644 index 0000000..0c569f5 --- /dev/null +++ b/src/components/molecules/forms/labelled-select.stories.tsx @@ -0,0 +1,195 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import LabelledSelectComponent from './labelled-select'; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; + +export default { + title: 'Molecules/Forms', + component: LabelledSelectComponent, + args: { + disabled: false, + labelPosition: 'top', + required: false, + }, + argTypes: { + disabled: { + control: { + type: 'boolean', + }, + description: 'Field state: either enabled or disabled.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'Field id.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'Field label.', + type: { + name: 'string', + required: true, + }, + }, + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + labelPosition: { + control: { + type: 'select', + }, + description: 'The label position.', + options: ['left', 'top'], + table: { + category: 'Options', + defaultValue: { summary: 'top' }, + }, + type: { + name: 'string', + required: false, + }, + }, + labelSize: { + control: { + type: 'select', + }, + description: 'The label size.', + options: ['medium', 'small'], + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'Field name.', + type: { + name: 'string', + required: true, + }, + }, + options: { + control: { + type: null, + }, + description: 'Select options.', + type: { + name: 'array', + required: true, + value: { + name: 'string', + }, + }, + }, + required: { + control: { + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + selectClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the select field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + setValue: { + control: { + type: null, + }, + description: 'Callback function to set field value.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: true, + }, + }, + value: { + control: { + type: null, + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof LabelledSelectComponent>; + +const Template: ComponentStory<typeof LabelledSelectComponent> = ({ + value, + setValue: _setValue, + ...args +}) => { + const [selected, setSelected] = useState<string>(value); + + return ( + <LabelledSelectComponent + value={selected} + setValue={setSelected} + {...args} + /> + ); +}; + +export const LabelledSelect = Template.bind({}); +LabelledSelect.args = { + id: 'labelled-select-storybook', + label: 'Labelled select', + name: 'labelled-select-storybook', + options: selectOptions, + value: 'option1', +}; diff --git a/src/components/molecules/forms/labelled-select.test.tsx b/src/components/molecules/forms/labelled-select.test.tsx new file mode 100644 index 0000000..9a50d6e --- /dev/null +++ b/src/components/molecules/forms/labelled-select.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@test-utils'; +import LabelledSelect from './labelled-select'; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; + +describe('LabelledSelect', () => { + it('renders a labelled select', () => { + render( + <LabelledSelect + id="jest-select-field" + name="jest-select-field" + label="Jest select field" + options={selectOptions} + value="option1" + setValue={() => null} + /> + ); + expect(screen.getByLabelText('Jest select field')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveValue('option1'); + }); +}); diff --git a/src/components/molecules/forms/labelled-select.tsx b/src/components/molecules/forms/labelled-select.tsx new file mode 100644 index 0000000..7d4237a --- /dev/null +++ b/src/components/molecules/forms/labelled-select.tsx @@ -0,0 +1,64 @@ +import Label, { LabelProps } from '@components/atoms/forms/label'; +import Select, { type SelectProps } from '@components/atoms/forms/select'; +import { VFC } from 'react'; +import styles from './labelled-select.module.scss'; + +export type LabelledSelectProps = Omit< + SelectProps, + 'aria-labelledby' | 'className' +> & { + /** + * The field label. + */ + label: string; + /** + * Set additional classnames to the label. + */ + labelClassName?: string; + /** + * The label position. Default: top. + */ + labelPosition?: 'left' | 'top'; + /** + * The label size. + */ + labelSize?: LabelProps['size']; + /** + * Set additional classnames to the select field. + */ + selectClassName?: string; +}; + +const LabelledSelect: VFC<LabelledSelectProps> = ({ + id, + label, + labelClassName = '', + labelPosition = 'top', + labelSize, + required, + selectClassName = '', + ...props +}) => { + const positionModifier = `label--${labelPosition}`; + + return ( + <> + <Label + htmlFor={id} + required={required} + size={labelSize} + className={`${styles[positionModifier]} ${labelClassName}`} + > + {label} + </Label> + <Select + id={id} + required={required} + {...props} + className={selectClassName} + /> + </> + ); +}; + +export default LabelledSelect; diff --git a/src/components/molecules/forms/motion-toggle.stories.tsx b/src/components/molecules/forms/motion-toggle.stories.tsx new file mode 100644 index 0000000..dc4d2a9 --- /dev/null +++ b/src/components/molecules/forms/motion-toggle.stories.tsx @@ -0,0 +1,31 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import MotionToggleComponent from './motion-toggle'; + +export default { + title: 'Molecules/Forms', + component: MotionToggleComponent, + argTypes: { + value: { + control: { + type: null, + }, + description: 'The reduce motion value.', + type: { + name: 'boolean', + required: true, + }, + }, + }, +} as ComponentMeta<typeof MotionToggleComponent>; + +const Template: ComponentStory<typeof MotionToggleComponent> = (args) => ( + <IntlProvider locale="en"> + <MotionToggleComponent {...args} /> + </IntlProvider> +); + +export const MotionToggle = Template.bind({}); +MotionToggle.args = { + value: false, +}; diff --git a/src/components/molecules/forms/motion-toggle.test.tsx b/src/components/molecules/forms/motion-toggle.test.tsx new file mode 100644 index 0000000..77bc17c --- /dev/null +++ b/src/components/molecules/forms/motion-toggle.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@test-utils'; +import MotionToggle from './motion-toggle'; + +describe('MotionToggle', () => { + it('renders a checked toggle (deactivate animations choice)', () => { + render(<MotionToggle value={true} />); + expect( + screen.getByRole('checkbox', { + name: `Animations: On Off`, + }) + ).toBeChecked(); + }); +}); diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx new file mode 100644 index 0000000..9f30b42 --- /dev/null +++ b/src/components/molecules/forms/motion-toggle.tsx @@ -0,0 +1,52 @@ +import Toggle, { + ToggleChoices, + ToggleProps, +} from '@components/molecules/forms/toggle'; +import { useState, VFC } from 'react'; +import { useIntl } from 'react-intl'; + +export type MotionToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>; + +/** + * MotionToggle component + * + * Render a Toggle component to set reduce motion. + */ +const MotionToggle: VFC<MotionToggleProps> = ({ value, ...props }) => { + const intl = useIntl(); + const [isDeactivated, setIsDeactivated] = useState<boolean>(value); + const reduceMotionLabel = intl.formatMessage({ + defaultMessage: 'Animations:', + description: 'MotionToggle: reduce motion label', + id: '/q5csZ', + }); + const onLabel = intl.formatMessage({ + defaultMessage: 'On', + description: 'MotionToggle: activate reduce motion label', + id: 'va65iw', + }); + const offLabel = intl.formatMessage({ + defaultMessage: 'Off', + description: 'MotionToggle: deactivate reduce motion label', + id: 'pWKyyR', + }); + const reduceMotionChoices: ToggleChoices = { + left: onLabel, + right: offLabel, + }; + + return ( + <Toggle + id="reduce-motion-settings" + name="reduce-motion-settings" + label={reduceMotionLabel} + labelSize="medium" + choices={reduceMotionChoices} + value={isDeactivated} + setValue={setIsDeactivated} + {...props} + /> + ); +}; + +export default MotionToggle; diff --git a/src/components/molecules/forms/prism-theme-toggle.stories.tsx b/src/components/molecules/forms/prism-theme-toggle.stories.tsx new file mode 100644 index 0000000..dc9090b --- /dev/null +++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx @@ -0,0 +1,31 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import PrismThemeToggleComponent from './prism-theme-toggle'; + +export default { + title: 'Molecules/Forms', + component: PrismThemeToggleComponent, + argTypes: { + value: { + control: { + type: null, + }, + description: 'The prism theme value.', + type: { + name: 'boolean', + required: true, + }, + }, + }, +} as ComponentMeta<typeof PrismThemeToggleComponent>; + +const Template: ComponentStory<typeof PrismThemeToggleComponent> = (args) => ( + <IntlProvider locale="en"> + <PrismThemeToggleComponent {...args} /> + </IntlProvider> +); + +export const PrismThemeToggle = Template.bind({}); +PrismThemeToggle.args = { + value: false, +}; diff --git a/src/components/molecules/forms/prism-theme-toggle.test.tsx b/src/components/molecules/forms/prism-theme-toggle.test.tsx new file mode 100644 index 0000000..0dceb92 --- /dev/null +++ b/src/components/molecules/forms/prism-theme-toggle.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@test-utils'; +import PrismThemeToggle from './prism-theme-toggle'; + +describe('PrismThemeToggle', () => { + it('renders a checked toggle (dark theme choice)', () => { + render(<PrismThemeToggle value={true} />); + expect( + screen.getByRole('checkbox', { + name: `Code blocks: Light theme Dark theme`, + }) + ).toBeChecked(); + }); +}); diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx new file mode 100644 index 0000000..daee6bd --- /dev/null +++ b/src/components/molecules/forms/prism-theme-toggle.tsx @@ -0,0 +1,57 @@ +import Moon from '@components/atoms/icons/moon'; +import Sun from '@components/atoms/icons/sun'; +import Toggle, { + ToggleChoices, + ToggleProps, +} from '@components/molecules/forms/toggle'; +import { useState, VFC } from 'react'; +import { useIntl } from 'react-intl'; + +export type PrismThemeToggleProps = Pick< + ToggleProps, + 'labelClassName' | 'value' +>; + +/** + * PrismThemeToggle component + * + * Render a Toggle component to set code blocks theme. + */ +const PrismThemeToggle: VFC<PrismThemeToggleProps> = ({ value, ...props }) => { + const intl = useIntl(); + const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value); + const themeLabel = intl.formatMessage({ + defaultMessage: 'Code blocks:', + description: 'PrismThemeToggle: theme label', + id: 'ftXN+0', + }); + const lightThemeLabel = intl.formatMessage({ + defaultMessage: 'Light theme', + description: 'PrismThemeToggle: light theme label', + id: 'tsWh8x', + }); + const darkThemeLabel = intl.formatMessage({ + defaultMessage: 'Dark theme', + description: 'PrismThemeToggle: dark theme label', + id: 'og/zWL', + }); + const themeChoices: ToggleChoices = { + left: <Sun title={lightThemeLabel} />, + right: <Moon title={darkThemeLabel} />, + }; + + return ( + <Toggle + id="prism-theme-settings" + name="prism-theme-settings" + label={themeLabel} + labelSize="medium" + choices={themeChoices} + value={isDarkTheme} + setValue={setIsDarkTheme} + {...props} + /> + ); +}; + +export default PrismThemeToggle; diff --git a/src/components/molecules/forms/select-with-tooltip.module.scss b/src/components/molecules/forms/select-with-tooltip.module.scss new file mode 100644 index 0000000..bfadece --- /dev/null +++ b/src/components/molecules/forms/select-with-tooltip.module.scss @@ -0,0 +1,48 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.wrapper { + display: flex; + flex-flow: row wrap; + align-items: center; + position: relative; +} + +.select { + width: auto; + + @include mix.pointer("fine") { + padding: fun.convert-px(3) var(--spacing-xs); + } +} + +.btn { + margin-left: var(--spacing-xs); + + &--activated { + background: var(--color-primary); + + * { + color: var(--color-fg-inverted); + } + } +} + +.tooltip { + position: absolute; + top: calc(100% + var(--spacing-xs)); + transform-origin: top; + transition: all 0.75s ease-in-out 0s; + + &--hidden { + opacity: 0; + visibility: hidden; + transform: scale(0); + } + + &--visible { + opacity: 1; + visibility: visible; + transform: scale(1); + } +} diff --git a/src/components/molecules/forms/select-with-tooltip.stories.tsx b/src/components/molecules/forms/select-with-tooltip.stories.tsx new file mode 100644 index 0000000..c63e9b8 --- /dev/null +++ b/src/components/molecules/forms/select-with-tooltip.stories.tsx @@ -0,0 +1,211 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import { IntlProvider } from 'react-intl'; +import SelectWithTooltipComponent from './select-with-tooltip'; + +export default { + title: 'Molecules/Forms', + component: SelectWithTooltipComponent, + argTypes: { + content: { + control: { + type: 'text', + }, + description: 'The tooltip body.', + type: { + name: 'string', + required: true, + }, + }, + disabled: { + control: { + type: 'boolean', + }, + description: 'Field state: either enabled or disabled.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'Field id.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'The select label.', + type: { + name: 'string', + required: true, + }, + }, + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + labelSize: { + control: { + type: 'select', + }, + description: 'The label size.', + options: ['medium', 'small'], + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'Field name.', + type: { + name: 'string', + required: true, + }, + }, + options: { + control: { + type: null, + }, + description: 'Select options.', + type: { + name: 'array', + required: true, + value: { + name: 'string', + }, + }, + }, + required: { + control: { + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + selectClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the select field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + setValue: { + control: { + type: null, + }, + description: 'Callback function to set field value.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: true, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The tooltip title', + type: { + name: 'string', + required: true, + }, + }, + tooltipClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the tooltip.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + value: { + control: { + type: 'text', + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof SelectWithTooltipComponent>; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; + +const Template: ComponentStory<typeof SelectWithTooltipComponent> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [selected, setSelected] = useState<string>('option1'); + return ( + <IntlProvider locale="en"> + <SelectWithTooltipComponent + value={selected} + setValue={setSelected} + {...args} + /> + </IntlProvider> + ); +}; + +export const SelectWithTooltip = Template.bind({}); +SelectWithTooltip.args = { + content: 'Illo voluptatibus quia minima placeat sit nostrum excepturi.', + title: 'Possimus quidem dolor', + id: 'storybook-select', + label: 'Officiis:', + name: 'storybook-select', + options: selectOptions, +}; diff --git a/src/components/molecules/forms/select-with-tooltip.test.tsx b/src/components/molecules/forms/select-with-tooltip.test.tsx new file mode 100644 index 0000000..7a423f5 --- /dev/null +++ b/src/components/molecules/forms/select-with-tooltip.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@test-utils'; +import SelectWithTooltip from './select-with-tooltip'; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; +const selectLabel = 'Jest select'; +const selectValue = selectOptions[0].value; +const tooltipTitle = 'Jest tooltip'; +const tooltipContent = 'Nesciunt voluptatibus voluptatem omnis at quia libero.'; + +describe('SelectWithTooltip', () => { + it('renders a select', () => { + render( + <SelectWithTooltip + id="jest-select" + name="jest-select" + label={selectLabel} + options={selectOptions} + value={selectValue} + setValue={() => null} + title={tooltipTitle} + content={tooltipContent} + /> + ); + expect(screen.getByRole('combobox', { name: selectLabel })).toHaveValue( + selectValue + ); + }); +}); diff --git a/src/components/molecules/forms/select-with-tooltip.tsx b/src/components/molecules/forms/select-with-tooltip.tsx new file mode 100644 index 0000000..f537e1e --- /dev/null +++ b/src/components/molecules/forms/select-with-tooltip.tsx @@ -0,0 +1,62 @@ +import { useState, VFC } from 'react'; +import HelpButton from '../buttons/help-button'; +import Tooltip, { type TooltipProps } from '../modals/tooltip'; +import LabelledSelect, { type LabelledSelectProps } from './labelled-select'; +import styles from './select-with-tooltip.module.scss'; + +export type SelectWithTooltipProps = Omit< + LabelledSelectProps, + 'labelPosition' +> & + Pick<TooltipProps, 'title' | 'content'> & { + /** + * The select label. + */ + label: string; + /** + * Set additional classnames to the tooltip wrapper. + */ + tooltipClassName?: string; + }; + +/** + * SelectWithTooltip component + * + * Render a select with a button to display a tooltip about options. + */ +const SelectWithTooltip: VFC<SelectWithTooltipProps> = ({ + title, + content, + id, + tooltipClassName = '', + ...props +}) => { + const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false); + const buttonModifier = isTooltipOpened ? styles['btn--activated'] : ''; + const tooltipModifier = isTooltipOpened + ? styles['tooltip--visible'] + : styles['tooltip--hidden']; + + return ( + <div className={styles.wrapper}> + <LabelledSelect + labelPosition="left" + id={id} + labelClassName={styles.label} + {...props} + /> + <HelpButton + onClick={() => setIsTooltipOpened(!isTooltipOpened)} + className={`${styles.btn} ${buttonModifier}`} + /> + <Tooltip + title={title} + content={content} + icon="?" + className={`${styles.tooltip} ${tooltipModifier} ${tooltipClassName}`} + /> + </div> + ); +}; + +export default SelectWithTooltip; diff --git a/src/components/molecules/forms/theme-toggle.stories.tsx b/src/components/molecules/forms/theme-toggle.stories.tsx new file mode 100644 index 0000000..a9bcf73 --- /dev/null +++ b/src/components/molecules/forms/theme-toggle.stories.tsx @@ -0,0 +1,31 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import ThemeToggleComponent from './theme-toggle'; + +export default { + title: 'Molecules/Forms', + component: ThemeToggleComponent, + argTypes: { + value: { + control: { + type: null, + }, + description: 'The theme value.', + type: { + name: 'boolean', + required: true, + }, + }, + }, +} as ComponentMeta<typeof ThemeToggleComponent>; + +const Template: ComponentStory<typeof ThemeToggleComponent> = (args) => ( + <IntlProvider locale="en"> + <ThemeToggleComponent {...args} /> + </IntlProvider> +); + +export const ThemeToggle = Template.bind({}); +ThemeToggle.args = { + value: false, +}; diff --git a/src/components/molecules/forms/theme-toggle.test.tsx b/src/components/molecules/forms/theme-toggle.test.tsx new file mode 100644 index 0000000..5cd3209 --- /dev/null +++ b/src/components/molecules/forms/theme-toggle.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@test-utils'; +import ThemeToggle from './theme-toggle'; + +describe('ThemeToggle', () => { + it('renders a checked toggle (dark theme choice)', () => { + render(<ThemeToggle value={true} />); + expect( + screen.getByRole('checkbox', { + name: `Theme: Light theme Dark theme`, + }) + ).toBeChecked(); + }); +}); diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx new file mode 100644 index 0000000..eb56ce9 --- /dev/null +++ b/src/components/molecules/forms/theme-toggle.tsx @@ -0,0 +1,54 @@ +import Moon from '@components/atoms/icons/moon'; +import Sun from '@components/atoms/icons/sun'; +import Toggle, { + ToggleChoices, + ToggleProps, +} from '@components/molecules/forms/toggle'; +import { useState, VFC } from 'react'; +import { useIntl } from 'react-intl'; + +export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>; + +/** + * ThemeToggle component + * + * Render a Toggle component to set theme. + */ +const ThemeToggle: VFC<ThemeToggleProps> = ({ value, ...props }) => { + const intl = useIntl(); + const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value); + const themeLabel = intl.formatMessage({ + defaultMessage: 'Theme:', + description: 'ThemeToggle: theme label', + id: 'suXOBu', + }); + const lightThemeLabel = intl.formatMessage({ + defaultMessage: 'Light theme', + description: 'ThemeToggle: light theme label', + id: 'Ygea7s', + }); + const darkThemeLabel = intl.formatMessage({ + defaultMessage: 'Dark theme', + description: 'ThemeToggle: dark theme label', + id: '2QwvtS', + }); + const themeChoices: ToggleChoices = { + left: <Sun title={lightThemeLabel} />, + right: <Moon title={darkThemeLabel} />, + }; + + return ( + <Toggle + id="theme-settings" + name="theme-settings" + label={themeLabel} + labelSize="medium" + choices={themeChoices} + value={isDarkTheme} + setValue={setIsDarkTheme} + {...props} + /> + ); +}; + +export default ThemeToggle; diff --git a/src/components/molecules/forms/toggle.module.scss b/src/components/molecules/forms/toggle.module.scss new file mode 100644 index 0000000..2e8a49f --- /dev/null +++ b/src/components/molecules/forms/toggle.module.scss @@ -0,0 +1,75 @@ +@use "@styles/abstracts/functions" as fun; + +.label { + --toggle-width: #{fun.convert-px(45)}; + --toggle-height: calc(var(--toggle-width) / 2); + + display: inline-flex; + align-items: center; + width: 100%; +} + +.title { + margin-right: var(--spacing-2xs); +} + +.toggle { + display: inline-flex; + align-items: center; + width: var(--toggle-width); + height: var(--toggle-height); + background: var(--color-shadow-light); + border: fun.convert-px(1) solid var(--color-primary); + border-radius: fun.convert-px(32); + box-shadow: inset 0 0 fun.convert-px(3) 0 var(--color-shadow-dark); + margin: 0 var(--spacing-2xs); + position: relative; + + &::after { + content: ""; + display: block; + width: calc((var(--toggle-width) / 2) - 1px); + height: calc((var(--toggle-width) / 2) - 1px); + background: var(--color-primary-light); + border: fun.convert-px(1) solid var(--color-primary); + border-radius: 50%; + box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(1) + var(--color-shadow), + 0 0 fun.convert-px(2) fun.convert-px(1) var(--color-shadow-light); + position: absolute; + left: fun.convert-px(-2); + transition: all 0.3s ease-in-out 0s; + } +} + +.checkbox { + position: absolute; + opacity: 0; + cursor: pointer; + + &:checked ~ .label { + .toggle::after { + position: absolute; + left: calc(100% - (var(--toggle-width) / 2) + #{fun.convert-px(2)}); + } + } + + &:hover, + &:focus { + ~ .label { + .toggle::after { + background: var(--color-primary-lighter); + } + } + } + + &:focus ~ .label { + .title { + text-decoration: underline solid var(--color-primary) fun.convert-px(2); + } + + .toggle { + outline: var(--color-border) solid fun.convert-px(5); + } + } +} diff --git a/src/components/molecules/forms/toggle.stories.tsx b/src/components/molecules/forms/toggle.stories.tsx new file mode 100644 index 0000000..078a34c --- /dev/null +++ b/src/components/molecules/forms/toggle.stories.tsx @@ -0,0 +1,117 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import ToggleComponent from './toggle'; + +export default { + title: 'Molecules/Forms', + component: ToggleComponent, + argTypes: { + choices: { + description: 'The toggle choices.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'The input id.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'The toggle label.', + type: { + name: 'string', + required: true, + }, + }, + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + labelSize: { + control: { + type: 'select', + }, + description: 'The label size.', + options: ['medium', 'small'], + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'The input name.', + type: { + name: 'string', + required: true, + }, + }, + setValue: { + control: { + type: null, + }, + description: 'A callback function to update the toggle value.', + type: { + name: 'function', + required: true, + }, + }, + value: { + control: { + type: null, + }, + description: 'The toggle value. True if checked.', + type: { + name: 'boolean', + required: true, + }, + }, + }, +} as ComponentMeta<typeof ToggleComponent>; + +const Template: ComponentStory<typeof ToggleComponent> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [isChecked, setIsChecked] = useState<boolean>(false); + return ( + <ToggleComponent value={isChecked} setValue={setIsChecked} {...args} /> + ); +}; + +export const Toggle = Template.bind({}); +Toggle.args = { + choices: { + left: 'On', + right: 'Off', + }, + id: 'toggle-example', + label: 'Activate setting:', + name: 'toggle-example', +}; diff --git a/src/components/molecules/forms/toggle.test.tsx b/src/components/molecules/forms/toggle.test.tsx new file mode 100644 index 0000000..fb97adc --- /dev/null +++ b/src/components/molecules/forms/toggle.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@test-utils'; +import Toggle from './toggle'; + +const choices = { + left: 'On', + right: 'Off', +}; + +const label = 'Activate this setting:'; + +describe('Toggle', () => { + it('renders a checked toggle', () => { + render( + <Toggle + id="toggle-example" + name="toggle-example" + choices={choices} + label={label} + value={true} + setValue={(__value) => null} + /> + ); + expect( + screen.getByRole('checkbox', { + name: `${label} ${choices.left} ${choices.right}`, + }) + ).toBeChecked(); + }); +}); diff --git a/src/components/molecules/forms/toggle.tsx b/src/components/molecules/forms/toggle.tsx new file mode 100644 index 0000000..dff2d2d --- /dev/null +++ b/src/components/molecules/forms/toggle.tsx @@ -0,0 +1,86 @@ +import Checkbox from '@components/atoms/forms/checkbox'; +import Label, { type LabelProps } from '@components/atoms/forms/label'; +import { ReactNode, VFC } from 'react'; +import styles from './toggle.module.scss'; + +export type ToggleChoices = { + /** + * The left part of the toggle field (unchecked). + */ + left: ReactNode; + /** + * The right part of the toggle field (checked). + */ + right: ReactNode; +}; + +export type ToggleProps = { + /** + * The toggle choices. + */ + choices: ToggleChoices; + /** + * The input id. + */ + id: string; + /** + * The toggle label. + */ + label: string; + /** + * Set additional classnames to the label. + */ + labelClassName?: string; + /** + * The label size. + */ + labelSize?: LabelProps['size']; + /** + * The input name. + */ + name: string; + /** + * The toggle value. True if checked. + */ + value: boolean; + /** + * A callback function to update the toggle value. + */ + setValue: (value: boolean) => void; +}; + +/** + * Toggle component + * + * Render a toggle with a label and two choices. + */ +const Toggle: VFC<ToggleProps> = ({ + choices, + id, + label, + labelClassName = '', + labelSize, + name, + setValue, + value, +}) => { + return ( + <> + <Checkbox + name={name} + id={id} + value={value} + setValue={() => setValue(!value)} + className={styles.checkbox} + /> + <Label size={labelSize} htmlFor={id} className={styles.label}> + <span className={`${styles.title} ${labelClassName}`}>{label}</span> + {choices.left} + <span className={styles.toggle}></span> + {choices.right} + </Label> + </> + ); +}; + +export default Toggle; |
