diff --git a/src/components/forms/controls/Radio/Radio.module.scss b/src/components/forms/controls/Radio/Radio.module.scss new file mode 100644 index 0000000..8aee3d1 --- /dev/null +++ b/src/components/forms/controls/Radio/Radio.module.scss @@ -0,0 +1,50 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@use '../../../../styling/defs.scss' as bk; + +@layer baklava.components { + .bk-radio { + @include bk.component-base(bk-radio); + + cursor: pointer; + + appearance: none; + width: 18px; + aspect-ratio: 1; + + border-radius: bk.$border-radius-circle; + background: transparent; + border: 1px solid bk.$theme-radio-default; + + // doing the inner cicle on checked / disabled+checked options without SVG. + background-clip: content-box; + padding: 3px; // distance between background filling and the center of border + // since the border is 2px when checked, it's center is 1px, thus for a distance of 2px we need to have 1 + 2 = 3px. + + &:checked { + background-color: bk.$theme-radio-selected; + border: 2px solid bk.$theme-radio-selected; + } + &:disabled { + border: 1px solid bk.$theme-radio-disabled; + background-color: transparent; + + &:checked { + border: 2px solid bk.$theme-radio-non-active; + background-color: bk.$theme-radio-non-active; + } + } + &:focus-visible, &.pseudo-focused { + // those have !important to override CSS rules on layer accessibility + outline: 2px solid bk.$theme-radio-focus !important; + outline-offset: 0 !important; + } + + @media (prefers-reduced-motion: no-preference) { + transition: none 100ms ease-out; + transition-property: background-color, background-position, border-color; + } + } +} diff --git a/src/components/forms/controls/Radio/Radio.stories.tsx b/src/components/forms/controls/Radio/Radio.stories.tsx new file mode 100644 index 0000000..5f4c0c0 --- /dev/null +++ b/src/components/forms/controls/Radio/Radio.stories.tsx @@ -0,0 +1,76 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { Meta, StoryObj } from '@storybook/react'; + +import * as React from 'react'; + +import { Radio } from './Radio.tsx'; + +import cl from './Radio.module.scss'; + + +type RadioArgs = React.ComponentProps; +type Story = StoryObj; + +export default { + component: Radio, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + }, + args: {}, + decorators: [ + Story =>
{ event.preventDefault(); }}>, + ], + render: (args) => , +} satisfies Meta; + + +export const Checked: Story = { + args: { defaultChecked: true }, +}; + +export const Unchecked: Story = { + args: {}, +}; + +export const DisabledSelected: Story = { + name: 'Disabled (selected)', + args: { + defaultChecked: true, + disabled: true, + }, +}; + +export const DisabledUnselected: Story = { + name: 'Disabled (unselected)', + args: { disabled: true }, +}; + +export const FocusedSelected: Story = { + name: 'Focused (selected)', + args: { + className: cl['pseudo-focused'], + defaultChecked: true, + }, +}; + +export const FocusedUnselected: Story = { + name: 'Focused (unselected)', + args: { + className: cl['pseudo-focused'], + }, +}; + +export const FocusedDisabledSelected: Story = { + name: 'Focused & Disabled (selected)', + args: { + className: cl['pseudo-focused'], + defaultChecked: true, + disabled: true, + }, +}; diff --git a/src/components/forms/controls/Radio/Radio.tsx b/src/components/forms/controls/Radio/Radio.tsx new file mode 100644 index 0000000..d086d83 --- /dev/null +++ b/src/components/forms/controls/Radio/Radio.tsx @@ -0,0 +1,37 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { classNames as cx, type ComponentProps } from '../../../../util/componentUtil.ts'; +import * as React from 'react'; + +import cl from './Radio.module.scss'; + + +export { cl as RadioClassNames }; + +export type RadioProps = ComponentProps<'input'> & { + /** Whether this component should be unstyled. */ + unstyled?: undefined | boolean, +}; +/** + * A simple Radio control, just the <input type="radio"> and nothing else.. + */ +export const Radio = (props: RadioProps) => { + const { + unstyled = false, + ...propsRest + } = props; + + return ( + + ); +}; diff --git a/src/components/forms/fields/RadioField/RadioField.module.scss b/src/components/forms/fields/RadioField/RadioField.module.scss new file mode 100644 index 0000000..3f175c2 --- /dev/null +++ b/src/components/forms/fields/RadioField/RadioField.module.scss @@ -0,0 +1,46 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@use '../../../../styling/defs.scss' as bk; + +@layer baklava.components { + .bk-radio-field { + @include bk.component-base(bk-radio-field); + + .bk-radio-field__title { + color: bk.$theme-text-label-default; + font-weight: bk.$font-weight-semibold; + margin-bottom: bk.$spacing-1; + + .bk-radio-field__title__icon { + font-size: 18px; + margin-left: bk.$spacing-1; + } + + .bk-radio-field__title__optional { + font-size: bk.$font-size-xs; + font-weight: bk.$font-weight-regular; + margin-left: bk.$spacing-1; + } + } + + .bk-radio-field__label { + display: flex; + align-items: flex-start; + } + + .bk-radio-field__label__content { + color: bk.$theme-text-label-default; + cursor: pointer; + position: relative; + padding-left: bk.$spacing-2; + top: -2px; + } + + .bk-radio-field__sublabel { + font-size: bk.$font-size-xs; + padding-left: 26px; + } + } +} diff --git a/src/components/forms/fields/RadioField/RadioField.stories.tsx b/src/components/forms/fields/RadioField/RadioField.stories.tsx new file mode 100644 index 0000000..a71cc06 --- /dev/null +++ b/src/components/forms/fields/RadioField/RadioField.stories.tsx @@ -0,0 +1,74 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { Meta, StoryObj } from '@storybook/react'; + +import * as React from 'react'; + +import { RadioField } from './RadioField.tsx'; + + +type RadioFieldArgs = React.ComponentProps; +type Story = StoryObj; + +export default { + component: RadioField, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + }, + args: {}, + decorators: [ + Story =>
{ event.preventDefault(); }}>, + ], + render: (args) => , +} satisfies Meta; + + +export const RadioFieldWithLabel: Story = { + args: { + label: 'Label', + }, +}; + +export const RadioFieldWithLabelAndTitle: Story = { + args: { + title: 'Title', + label: 'Label', + }, +}; + +export const RadioFieldWithLabelWithTitleWithTooltip: Story = { + args: { + title: 'Title', + label: 'Label', + titleTooltip: 'This is a tooltip', + } +}; + +export const RadioFieldWithLabelWithTitleWithOptional: Story = { + args: { + title: 'Title', + label: 'Label', + optional: true, + }, +}; + +export const RadioFieldWithLabelWithTitleWithTooltipWithOptional: Story = { + args: { + title: 'Title', + label: 'Label', + titleTooltip: 'This is a tooltip', + optional: true, + }, +}; + +export const RadioFieldWithLabelAndSublabel: Story = { + args: { + label: 'Label', + sublabel: 'Supporting copy', + }, +}; diff --git a/src/components/forms/fields/RadioField/RadioField.tsx b/src/components/forms/fields/RadioField/RadioField.tsx new file mode 100644 index 0000000..730bdcf --- /dev/null +++ b/src/components/forms/fields/RadioField/RadioField.tsx @@ -0,0 +1,122 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { classNames as cx, type ComponentProps, type ClassNameArgument } from '../../../../util/componentUtil.ts'; +import * as React from 'react'; + +import { Radio } from '../../controls/Radio/Radio.tsx'; +import { Icon } from '../../../graphics/Icon/Icon.tsx'; +import { TooltipProvider } from '../../../overlays/Tooltip/TooltipProvider.tsx'; + +import cl from './RadioField.module.scss'; + + +export { cl as RadioFieldClassNames }; + +export type RadioTitleProps = React.PropsWithChildren<{ + className?: ClassNameArgument, + + /** Whether to display the optional observation on title. */ + optional?: undefined | boolean, + + /** An optional tooltip to be displayed on an info icon next to the title. */ + titleTooltip?: undefined | string, +}>; + +export const RadioFieldTitle = ({ className, children, optional, titleTooltip }: RadioTitleProps) => ( +

+ {children} + {titleTooltip && ( + + + + )} + {optional && ( + (Optional) + )} +

+); + +export type RadioFieldProps = ComponentProps<'div'> & { + /** Whether this component should be unstyled. */ + unstyled?: undefined | boolean, + + /** A label to be displayed after the radio button. */ + label: string, + + /** An optional supporting copy to be displayed under the label. */ + sublabel?: undefined | string, + + /** An optional title. */ + title?: undefined | string, + + /** An optional tooltip to be displayed on an info icon next to the title. */ + titleTooltip?: undefined | string, + + /** Whether to display the optional observation on title. */ + optional?: undefined | boolean, + + /** Whether the radio is selected by default. Passed down to Radio component. */ + defaultChecked?: undefined | boolean, + + /** Whether the radio is selected. Passed down to Radio component. */ + checked?: undefined | boolean, + + /** Whether the radio is disabled. Passed down to Radio component. */ + disabled?: undefined | boolean, + + /** The onChange event for the radio. Passed down to Radio component. */ + onChange?: (e: React.FormEvent) => void, +}; + +/** + * A full-fledged Radio field, with optional label, title, icon etc. + */ +export const RadioField = (props: RadioFieldProps) => { + const { + unstyled = false, + label = '', + sublabel, + title, + optional, + titleTooltip, + className, + } = props; + + return ( +
+ {title && ( + + {title} + + )} + {/* biome ignore lint/a11y/noLabelWithoutControl: the `` will resolve to an `` */} + + {sublabel && ( +
{sublabel}
+ )} +
+ ); +}; diff --git a/src/components/forms/fields/RadioGroup/RadioGroup.module.scss b/src/components/forms/fields/RadioGroup/RadioGroup.module.scss new file mode 100644 index 0000000..d84fc67 --- /dev/null +++ b/src/components/forms/fields/RadioGroup/RadioGroup.module.scss @@ -0,0 +1,22 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@use '../../../../styling/defs.scss' as bk; + +@layer baklava.components { + .bk-radio-group { + @include bk.component-base(bk-radio-group); + display: flex; + + &.bk-radio-group--vertical { + flex-direction: column; + row-gap: 20px; + } + + &.bk-radio-group--horizontal { + flex-direction: row; + column-gap: bk.$spacing-7; + } + } +} diff --git a/src/components/forms/fields/RadioGroup/RadioGroup.stories.tsx b/src/components/forms/fields/RadioGroup/RadioGroup.stories.tsx new file mode 100644 index 0000000..f2d3309 --- /dev/null +++ b/src/components/forms/fields/RadioGroup/RadioGroup.stories.tsx @@ -0,0 +1,51 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { RadioGroup } from './RadioGroup.tsx'; + + +type RadioGroupArgs = React.ComponentProps; +type Story = StoryObj; + +const Color = ['Red', 'Green', 'Blue'] as const; +type Color = (typeof Color)[number]; + +export default { + component: RadioGroup, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + }, + render: (args) => { + const [selectedColor, setSelectedColor] = React.useState(Color[0]); + // TODO: how to make a typed element? such as {...args}> + return ( + + {Color.map(color => + setSelectedColor(color)} + /> + )} + + ); + }, +} satisfies Meta; + +export const RadioGroupVertical: Story = {}; + +export const RadioGroupHorizontal: Story = { + args: { + direction: 'horizontal', + }, +}; diff --git a/src/components/forms/fields/RadioGroup/RadioGroup.tsx b/src/components/forms/fields/RadioGroup/RadioGroup.tsx new file mode 100644 index 0000000..668952b --- /dev/null +++ b/src/components/forms/fields/RadioGroup/RadioGroup.tsx @@ -0,0 +1,37 @@ +/* Copyright (c) Fortanix, Inc. +|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { classNames as cx } from '../../../../util/componentUtil.ts'; +import * as React from 'react'; + +import cl from './RadioGroup.module.scss'; + +import { RadioField } from '../RadioField/RadioField.tsx'; + + +export { cl as RadioGroupClassNames }; + +export type RadioGroupProps = React.PropsWithChildren<{ + direction?: undefined | "vertical" | "horizontal", +}>; + +/** + * Radio group component, wrapping multiple RadioField components vertically or horizontally. + */ +export const RadioGroup = Object.assign( + (props: RadioGroupProps) => { + const { children, direction = 'vertical' } = props; + return ( +
+ {children} +
+ ); + }, + { RadioField }, +);