diff --git a/.storybook/public/animations/mathematics_animated_subject_icon.riv b/.storybook/public/animations/mathematics_animated_subject_icon.riv new file mode 100644 index 0000000000..9d96423a99 Binary files /dev/null and b/.storybook/public/animations/mathematics_animated_subject_icon.riv differ diff --git a/.storybook/public/animations/physics_animated_subject_icon.riv b/.storybook/public/animations/physics_animated_subject_icon.riv new file mode 100644 index 0000000000..8f13bcf09b Binary files /dev/null and b/.storybook/public/animations/physics_animated_subject_icon.riv differ diff --git a/.storybook/public/images/avatar_10.png b/.storybook/public/images/avatar_10.png new file mode 100644 index 0000000000..aa8b149b95 Binary files /dev/null and b/.storybook/public/images/avatar_10.png differ diff --git a/.storybook/public/images/avatar_16.png b/.storybook/public/images/avatar_16.png new file mode 100644 index 0000000000..1b8b313d09 Binary files /dev/null and b/.storybook/public/images/avatar_16.png differ diff --git a/.storybook/public/images/calipers.svg b/.storybook/public/images/calipers.svg new file mode 100644 index 0000000000..b454cc6576 --- /dev/null +++ b/.storybook/public/images/calipers.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.storybook/public/images/image_1.jpeg b/.storybook/public/images/image_1.jpeg new file mode 100644 index 0000000000..50f0d5aba8 Binary files /dev/null and b/.storybook/public/images/image_1.jpeg differ diff --git a/.storybook/public/images/image_2.jpeg b/.storybook/public/images/image_2.jpeg new file mode 100644 index 0000000000..b916b1ea4a Binary files /dev/null and b/.storybook/public/images/image_2.jpeg differ diff --git a/deprecated.json b/deprecated.json index 80fd906c88..541b093efa 100644 --- a/deprecated.json +++ b/deprecated.json @@ -10,6 +10,7 @@ {"componentName": "HeaderContent", "className": "sg-header__content"}, {"componentName": "HeaderLeft", "className": "sg-header__left"}, {"componentName": "HeaderMiddle", "className": "sg-header__middle"}, - {"componentName": "HeaderRight", "className": "sg-header__right"} + {"componentName": "HeaderRight", "className": "sg-header__right"}, + {"componentName": "Card", "className": "sg-card"} ] } diff --git a/package.json b/package.json index 52d0041e5d..7eb7ac700a 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@hot-loader/react-dom": "^16.8.6", "@khanacademy/flow-to-ts": "^0.5.2", "@microsoft/api-extractor": "^7.33.8", + "@rive-app/react-canvas": "^3.0.38", "@storybook/addon-a11y": "^6.5.13", "@storybook/addon-actions": "^6.5.13", "@storybook/addon-docs": "^6.5.13", diff --git a/src/components/card-interactive/CardCheckbox.chromatic.stories.tsx b/src/components/card-interactive/CardCheckbox.chromatic.stories.tsx new file mode 100644 index 0000000000..acca1b2578 --- /dev/null +++ b/src/components/card-interactive/CardCheckbox.chromatic.stories.tsx @@ -0,0 +1,9 @@ +import * as CardCheckbox from './CardCheckbox.stories.mdx'; +import {generateChromaticStory} from '../../chromatic/utils'; + +export const Default = generateChromaticStory(CardCheckbox, { + storiesToHover: ['statesDark', 'statesLight'], +}); +const {includeStories, ...meta} = CardCheckbox.default; + +export default meta; diff --git a/src/components/card-interactive/CardCheckbox.spec.tsx b/src/components/card-interactive/CardCheckbox.spec.tsx new file mode 100644 index 0000000000..6168e63e9f --- /dev/null +++ b/src/components/card-interactive/CardCheckbox.spec.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; +import CardCheckbox from './CardCheckbox'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {testA11y} from '../../axe'; + +describe('', () => { + it('renders unchecked checkbox input, without label', () => { + const checkbox = render(); + const checkboxInput = checkbox.getByRole('checkbox') as HTMLInputElement; + + expect(checkboxInput.checked).toBe(false); + }); + + it('renders CardRadio with accessible name and checkbox role', () => { + const label = 'Option A'; + + render({label}); + const checkboxInput = screen.getByRole('checkbox', { + name: label, + }) as HTMLInputElement; + + expect(checkboxInput).toBeInTheDocument(); + expect(checkboxInput.checked).toBe(false); + }); + + it('does not allow checking disabled item', () => { + render(Option A); + userEvent.click(screen.getByLabelText('Option A')); + expect(screen.getByLabelText('Option A')).not.toBeChecked(); + }); + + it('checks/unchecks when either checkbox, input or label is clicked or space is pressed', () => { + const checkbox = render(my label); + const checkboxInput = checkbox.getByRole('checkbox') as HTMLInputElement; + + expect(checkboxInput.checked).toBe(false); + userEvent.click(checkboxInput); + expect(checkboxInput).toEqual(document.activeElement); + + expect(checkboxInput.checked).toBe(true); + userEvent.keyboard('{space}'); + expect(checkboxInput.checked).toBe(false); + userEvent.click(checkbox.getByLabelText('my label')); + expect(checkboxInput.checked).toBe(true); + }); + + it('allows aria-labbelledby to override accessible name', () => { + render( + + Option ACustom Label Option A + + ); + + const checkboxInput = screen.getByRole('checkbox', { + name: 'Custom Label Option A', + }) as HTMLInputElement; + + expect(checkboxInput).toBeInTheDocument(); + }); + + it('allows aria-descriibedby to define accessible description', () => { + render( + + Option ACustom Description Option A + + ); + + const checkboxInput = screen.getByRole('checkbox', { + description: 'Custom Description Option A', + }) as HTMLInputElement; + + expect(checkboxInput).toBeInTheDocument(); + }); + + it('it renders as checked by default', () => { + const checkbox = render( + my label + ); + const checkboxInput = checkbox.getByRole('checkbox') as HTMLInputElement; + + expect(checkboxInput.checked).toBe(true); + }); + + it('renders as unchecked by default', () => { + const checkbox = render( + my label + ); + const checkboxInput = checkbox.getByRole('checkbox') as HTMLInputElement; + + expect(checkboxInput.checked).toBe(false); + }); + + it('responds to check/uncheck when controlled', () => { + const ControlledCheckbox = () => { + const [checked, setChecked] = React.useState(false); + + return ( + setChecked(val => !val)} + aria-labelledby="label" + > + + + ); + }; + + const checkbox = render(); + const checkboxInput = checkbox.getByRole('checkbox') as HTMLInputElement; + + expect(checkboxInput.checked).toBe(false); + userEvent.click(checkboxInput); + expect(checkboxInput.checked).toBe(true); + userEvent.click(checkboxInput); + expect(checkboxInput.checked).toBe(false); + }); + + describe('a11y', () => { + it('should have no a11y violations when children is provided', async () => { + await testA11y( + + + + ); + }); + + it('should have no a11y violations when checked', async () => { + await testA11y( + + + + ); + }); + + it('should have no a11y violations when disabled', async () => { + await testA11y( + + + + ); + }); + + it('should have no a11y violations when required', async () => { + await testA11y( + + + + ); + }); + }); +}); diff --git a/src/components/card-interactive/CardCheckbox.stories.mdx b/src/components/card-interactive/CardCheckbox.stories.mdx new file mode 100644 index 0000000000..0230fc0b97 --- /dev/null +++ b/src/components/card-interactive/CardCheckbox.stories.mdx @@ -0,0 +1,598 @@ +import {ArgsTable, Meta, Story, Canvas} from '@storybook/addon-docs'; +import {StoryVariantTable} from '../../docs/utils'; +import PageHeader from 'blocks/PageHeader'; +import {useRive, useStateMachineInput} from '@rive-app/react-canvas'; + +import Flex from '../flex/Flex'; +import Text from '../text/Text'; +import Headline from '../text/Headline'; +import SubjectIcon from '../subject-icons/SubjectIcon'; + +import CardCheckbox from './CardCheckbox'; +import CardCheckboxA11y from './stories/CardCheckbox.a11y.mdx'; +import CardCustomisation from './stories/CardCustomisation.mdx'; + + + +CardCheckbox + +- [Stories](#stories) +- [Accessibility](#accessibility) + +## Overview + + + ( +
+ +
+ ), + ]} + args={{ + defaultChecked: true, + }} + > + {args => ( + + + + + Placeholder + + + + )} +
+
+ + + +## Stories + +### Variants + +#### Solid + + + ( +
+ +
+ ), + ]} + > + {args => ( + <> + + + DARK + + + + + + + Knowledge + + + + + + + LIGHT + + + + +
+ +
+ + + High School + + +
+
+
+ + )} +
+
+ +#### Outline + + + ( +
+ +
+ ), + ]} + > + {args => ( + <> + + + DARK + + + + + + + Philosophy + + + + + + + LIGHT + + + + + + + Geometry + + + + + + )} +
+
+ +### Indicator position + + + ( +
+ +
+ ), + ]} + > + {args => ( + + + + + + + + + + Placeholder + + + + )} +
+
+ +### States + +#### Dark + + + + {args => ( + + + + + + + default + + + + + disabled + + + + + error + + + + + + {['outline', 'solid'].map(variant => { + return [false, true].map(indicator => { + return [false, true].map(checked => { + const states = ['default', 'disabled', 'error'].map(states => ( + + + {indicator && } + + + Placeholder + + + + + )); + return ( + + + + {variant} +
+ {checked ? 'checked' : 'unchecked'} +
+ {indicator + ? 'with indicator' + : 'without indicator'}{' '} +
+
+ + {states} + + ); + }); + }); + })} + +
+ )} +
+
+ +#### Light + + + ( +
+ +
+ ), + ]} + > + {args => ( + + + + + + + default + + + + + disabled + + + + + error + + + + + + {['outline', 'solid'].map(variant => { + return [false, true].map(indicator => { + return [false, true].map(checked => { + const states = ['default', 'disabled', 'error'].map(states => ( + + + {indicator && } + + + Placeholder + + + + + )); + return ( + + + + {variant} +
+ {checked ? 'checked' : 'unchecked'} +
+ {indicator + ? 'with indicator' + : 'without indicator'}{' '} +
+
+ + {states} + + ); + }); + }); + })} + +
+ )} +
+
+ +### Examples + +#### Animated icons + + + ( +
+ +
+ ), + ]} + > + {args => { + const AnimatedCard = (filename, label) => { + const STATE_MACHINE_NAME = 'State Machine 1'; + const {rive, RiveComponent} = useRive({ + src: `animations/${filename}.riv`, + autoplay: true, + stateMachines: STATE_MACHINE_NAME, + }); + const isHoverInput = useStateMachineInput( + rive, + STATE_MACHINE_NAME, + 'Hover', + false + ); + return ( + { + isHoverInput.value = true; + }} + onMouseLeave={() => { + isHoverInput.value = false; + }} + > + +
+ +
+ + {label} + +
+
+ ); + }; + return ( + <> + {AnimatedCard('mathematics_animated_subject_icon', 'mathematics')} + {AnimatedCard('physics_animated_subject_icon', 'physics')} + {AnimatedCard('mathematics_animated_subject_icon', 'mathematics')} + {AnimatedCard('physics_animated_subject_icon', 'physics')} + + ); + }} +
+
+ +## Customisation + + + +## Accessibility + + diff --git a/src/components/card-interactive/CardCheckbox.tsx b/src/components/card-interactive/CardCheckbox.tsx new file mode 100644 index 0000000000..1257ae9fee --- /dev/null +++ b/src/components/card-interactive/CardCheckbox.tsx @@ -0,0 +1,282 @@ +import * as React from 'react'; +import cx from 'classnames'; +import Checkbox from '../form-elements/checkbox/Checkbox'; +import generateRandomString from '../../js/generateRandomString'; + +export interface CardCheckboxPropsType + extends Omit, 'onChange'> { + /** + * Optional string. Variant of the card. Default is 'outline'. + */ + variant?: 'solid' | 'outline'; + + color?: 'light' | 'dark'; + + /** + * Optional string. Additional class names. + */ + className?: string; + + /** + * Optional React.ReactNode. Children of the card. This is the place where label should be used and connected to the card. + * @example Card content + */ + children?: React.ReactNode; + + /** + * Optional string. Width of the card. + * @example + **/ + width?: React.CSSProperties['width']; + + /** + * Optional string. Height of the card. + * @example + */ + height?: React.CSSProperties['height']; + + /** + * Optional object. Inline styles. + * @example + */ + style?: React.CSSProperties; + + /** + * Optional boolean. Whether the checkbox is checked. + */ + checked?: boolean; + + /** + * Optional boolean. Whether the checkbox is checked by default. Only works when `checked` is not defined. + */ + defaultChecked?: boolean; + + /** + * Optional boolean. Whether the checkbox is disabled. + */ + disabled?: boolean; + + /** + * Optional string. ID of the checkbox. + */ + id?: string; + + /** + * Sets whether the checkbox is displayed as indeterminate. Note: this prop doesn't modify the `checked` property. + * @example + * @default false + */ + indeterminate?: boolean; + + /** + * Optional boolean. Whether the checkbox is invalid. + * @default + */ + invalid?: boolean; + + /** + * Optional boolean. Whether the checkbox is required. + * @default + */ + required?: boolean; + + /** + * Value of the Card.Checkbox input. + * @example + */ + value?: string; + + /** + * Name of the Card.Checkbox input. + * @example + */ + name?: string; + + /** + * Function called whenever the state of the checkbox changes. + */ + onChange?: (e: React.ChangeEvent) => void; + + /** + * Optional string. ID of the element that labels the Radio. + * @example + **/ + 'aria-labelledby'?: string; + + /** + * Optional string. ID of the element that describes the Radio. + * @example + **/ + 'aria-describedby'?: string; +} + +type CardCheckboxContextType = { + checked: boolean; + disabled: boolean; + indeterminate: boolean; +}; + +export const CardCheckboxContext = React.createContext( + { + checked: false, + disabled: false, + indeterminate: false, + } +); + +export const CardCheckboxRoot = React.forwardRef< + HTMLInputElement, + CardCheckboxPropsType +>( + ( + { + variant = 'outline', + color = 'dark', + className, + children, + width, + height, + style, + + // checkbox related props + checked, + defaultChecked = false, + disabled, + id, + indeterminate, + invalid = false, + required = false, + value, + name, + onChange, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + ...props + }: CardCheckboxPropsType, + ref + ) => { + const isControlled = checked !== undefined; + const [isChecked, setIsChecked] = React.useState( + isControlled ? checked : defaultChecked + ); + + const cardId = React.useMemo(() => id || generateRandomString(), [id]); + + const cssVariables = { + '--card-width': width, + '--card-height': height, + }; + + const onInputChange = React.useCallback( + e => { + if (!isControlled) { + setIsChecked(val => !val); + } + + if (onChange) onChange(e); + }, + [onChange, isControlled] + ); + + React.useEffect(() => { + if (isControlled) { + setIsChecked(checked); + } + }, [checked, isControlled]); + + return ( + + + + ); + } +); + +export interface CardCheckboxIndicatorPropsType { + slot?: + | 'top-left' + | 'center-left' + | 'bottom-left' + | 'top-right' + | 'center-right' + | 'bottom-right'; + style?: React.CSSProperties; + className?: string; +} + +export const CardCheckboxIndicator = ({ + slot = 'top-left', + style, + className, +}: CardCheckboxIndicatorPropsType) => { + const {checked, disabled, indeterminate} = + React.useContext(CardCheckboxContext); + + return ( +
+ +
+ ); +}; + +const CardCheckbox = Object.assign(CardCheckboxRoot, { + Indicator: CardCheckboxIndicator, +}); + +CardCheckbox.displayName = 'CardCheckbox'; +CardCheckboxIndicator.displayName = 'CardCheckbox.Indicator'; + +export default CardCheckbox; diff --git a/src/components/card-interactive/CardRadio.spec.tsx b/src/components/card-interactive/CardRadio.spec.tsx new file mode 100644 index 0000000000..ff3a3b8c99 --- /dev/null +++ b/src/components/card-interactive/CardRadio.spec.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {testA11y} from '../../axe'; + +import CardRadioGroup from './CardRadioGroup'; +import {CardRadio} from './CardRadio'; + +type Optional = Omit & Partial; + +describe('', () => { + const renderCardRadio = ( + props?: Optional, 'value'> + ) => + render( + + + Option A + + + ); + + it('renders CardRadio with accessible name and radio role', () => { + const label = 'Option A'; + + renderCardRadio(); + + const cardRadio = screen.getByRole('radio', { + name: label, + }) as HTMLInputElement; + + expect(cardRadio).toBeInTheDocument(); + expect(cardRadio.checked).toBe(false); + expect(cardRadio.getAttribute('value')).toBe('option-a'); + }); + + it('does not allow checking disabled item', () => { + renderCardRadio({disabled: true}); + userEvent.click(screen.getByLabelText('Option A')); + expect(screen.getByLabelText('Option A')).not.toBeChecked(); + }); + + it('allows aria-labbelledby to override accessible name', () => { + render( + + + Option ACustom Label Option A + + + ); + + const cardRadio = screen.getByRole('radio', { + name: 'Custom Label Option A', + }) as HTMLInputElement; + + expect(cardRadio).toBeInTheDocument(); + }); + + it('allows aria-descriibedby to define accessible description', () => { + render( + + + Option ACustom Description Option A + + + ); + + const cardRadio = screen.getByRole('radio', { + description: 'Custom Description Option A', + }) as HTMLInputElement; + + expect(cardRadio).toBeInTheDocument(); + }); + + describe('a11y', () => { + it('should have no a11y violations', async () => { + const {container} = renderCardRadio(); + + await testA11y(container); + }); + + it('should have no a11y violations when disabled', async () => { + const {container} = renderCardRadio({disabled: true}); + + await testA11y(container); + }); + + it('should have no a11y violations when invalid', async () => { + const {container} = renderCardRadio({invalid: true}); + + await testA11y(container); + }); + + it('should have no a11y violations when checked', async () => { + const {container} = render( + + Option A + + ); + + await testA11y(container); + }); + + it('should have no a11y violations when required', async () => { + const {container} = renderCardRadio({required: true}); + + await testA11y(container); + }); + }); +}); diff --git a/src/components/card-interactive/CardRadio.tsx b/src/components/card-interactive/CardRadio.tsx new file mode 100644 index 0000000000..1d9235f9f3 --- /dev/null +++ b/src/components/card-interactive/CardRadio.tsx @@ -0,0 +1,235 @@ +import * as React from 'react'; +import cx from 'classnames'; +import Radio from '../form-elements/radio/Radio'; +import generateRandomString from '../../js/generateRandomString'; +import {useCardRadioGroupContext} from './CardRadioGroupContext'; +import type {StyleType} from './types'; + +export interface CardRadioPropsType + extends Omit, 'onChange'> { + /** + * Required string. Value of the CardRadio input. + */ + value: string; + + /** + * Optional boolean. Whether the Radio is required. + * @default false + */ + required?: boolean; + + /** + * Optional boolean. Whether the Radio is disabled. + * @default false + */ + disabled?: boolean; + + /** + * Optional boolean. Whether the Radio is invalid. + * @default false + */ + invalid?: boolean; + + /** + * Optional string. ID of the Radio. + */ + id?: string; + + /** + * Optional string. Variant of the card. Default is 'outline'. + */ + variant?: 'solid' | 'outline'; + + /** + * Optional string. Color of the card. Default is 'dark'. + */ + color?: 'light' | 'dark'; + + /** + * Optional string. Additional class names. + */ + className?: string; + + /** + * Optional React.ReactNode. Children of the card. This is the place where label should be used and connected to the card. + * @example Card content + */ + children?: React.ReactNode; + + /** + * Optional string. Width of the card. + * @example + */ + width?: React.CSSProperties['width']; + + /** + * Optional string. Height of the card. + * @example + */ + height?: React.CSSProperties['height']; + + /** + * Optional object. Inline styles. + * @example + */ + style?: StyleType; + + /** + * Function called whenever the state of the Radio changes. + */ + onChange?: (e: React.ChangeEvent) => void; + + /** + * Optional string. ID of the element that labels the Radio. + * @example + **/ + 'aria-labelledby'?: string; + + /** + * Optional string. ID of the element that describes the Radio. + * @example + **/ + 'aria-describedby'?: string; +} + +type CardRadioContextType = { + checked: boolean; + disabled: boolean; +}; + +export const CardRadioContext = React.createContext({ + checked: false, + disabled: false, +}); + +const CardRadio = React.forwardRef( + ( + { + variant = 'outline', + color = 'dark', + className, + children, + width, + height, + style, + + // radio related props + id, + disabled, + required = false, + invalid = false, + value, + onChange, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + ...props + }: CardRadioPropsType, + ref + ) => { + const context = useCardRadioGroupContext(); + + const cardId = React.useMemo(() => id || generateRandomString(), [id]); + const isChecked = context.value === value; + const isRequired = context.required || required; + const isDisabled = context.disabled || disabled; + const isInvalid = context.invalid || invalid; + + const cssVariables = { + '--card-width': width, + '--card-height': height, + }; + + const handleInputChange = (e: React.ChangeEvent) => { + if (context.onChange) { + context.onChange(e.target.value); + } + + if (onChange) { + onChange(e); + } + }; + + return ( + + + + ); + } +); + +export interface CardRadioIndicatorPropsType { + slot?: + | 'top-left' + | 'center-left' + | 'bottom-left' + | 'top-right' + | 'center-right' + | 'bottom-right'; + style?: React.CSSProperties; + className?: string; +} + +export function CardRadioIndicator({ + slot = 'top-left', + style, + className, +}: CardRadioIndicatorPropsType) { + const {checked, disabled} = React.useContext(CardRadioContext); + + return ( +
+ +
+ ); +} + +CardRadioIndicator.displayName = 'CardRadioIndicator'; + +export {CardRadio}; diff --git a/src/components/card-interactive/CardRadioGroup.chromatic.stories.tsx b/src/components/card-interactive/CardRadioGroup.chromatic.stories.tsx new file mode 100644 index 0000000000..2f8d3735ef --- /dev/null +++ b/src/components/card-interactive/CardRadioGroup.chromatic.stories.tsx @@ -0,0 +1,9 @@ +import * as CardRadioGroup from './CardRadioGroup.stories.mdx'; +import {generateChromaticStory} from '../../chromatic/utils'; + +export const Default = generateChromaticStory(CardRadioGroup, { + storiesToHover: ['statesDark', 'statesLight'], +}); +const {includeStories, ...meta} = CardRadioGroup.default; + +export default meta; diff --git a/src/components/card-interactive/CardRadioGroup.spec.tsx b/src/components/card-interactive/CardRadioGroup.spec.tsx new file mode 100644 index 0000000000..e3f24d02a9 --- /dev/null +++ b/src/components/card-interactive/CardRadioGroup.spec.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {testA11y} from '../../axe'; + +import CardRadioGroup from './CardRadioGroup'; + +describe('', () => { + const renderCardRadioGroup = ( + props?: Omit, 'children'> + ) => + render( + + Option A + Option B + + ); + + it('renders CardRadioGroup with CardRadios', () => { + renderCardRadioGroup(); + expect(screen.getByLabelText('Option A')).toBeInTheDocument(); + expect(screen.getByLabelText('Option B')).toBeInTheDocument(); + }); + + it('does not allow checking disabled CardRadio', () => { + renderCardRadioGroup({disabled: true}); + userEvent.click(screen.getByLabelText('Option A')); + expect(screen.getByLabelText('Option A')).not.toBeChecked(); + }); + + it('changes selected element when CardRadio is clicked', () => { + renderCardRadioGroup(); + userEvent.click(screen.getByLabelText('Option A')); + expect(screen.getByLabelText('Option A')).toBeChecked(); + expect(screen.getByLabelText('Option B')).not.toBeChecked(); + userEvent.click(screen.getByLabelText('Option B')); + expect(screen.getByLabelText('Option A')).not.toBeChecked(); + expect(screen.getByLabelText('Option B')).toBeChecked(); + }); + + it('calls onChange when CardRadio is clicked', () => { + const onChange = jest.fn(); + + renderCardRadioGroup({onChange}); + userEvent.click(screen.getByLabelText('Option A')); + expect(onChange).toHaveBeenCalledWith('option-a'); + }); + + it('checked CardRadio can be changed on controlled CardRadioGroup', () => { + const {rerender} = render( + + Option A + Option B + + ); + + expect(screen.getByLabelText('Option A')).toBeChecked(); + expect(screen.getByLabelText('Option B')).not.toBeChecked(); + + rerender( + + Option A + Option B + + ); + expect(screen.getByLabelText('Option A')).not.toBeChecked(); + expect(screen.getByLabelText('Option B')).toBeChecked(); + }); + + it('has an accessible name', () => { + renderCardRadioGroup({'aria-label': 'test'}); + expect(screen.getByLabelText('test')).toBeInTheDocument(); + }); + + it('has an accessible description', () => { + render( + +

description

+ Option A + Option B +
+ ); + + expect( + screen.getByRole('radiogroup', {description: 'description'}) + ).toBeInTheDocument(); + }); + + describe('a11y', () => { + it('should have no a11y violations', async () => { + const {container} = renderCardRadioGroup(); + + await testA11y(container); + }); + + it('should have no a11y violations when required', async () => { + const {container} = renderCardRadioGroup({required: true}); + + await testA11y(container); + }); + + it('should have no a11y violations when value is provided', async () => { + const {container} = renderCardRadioGroup({value: 'option-a'}); + + await testA11y(container); + }); + + it('should have no a11y violations when disabled', async () => { + const {container} = renderCardRadioGroup({disabled: true}); + + await testA11y(container); + }); + + it('should have no a11y violations when label is provided', async () => { + const {container} = renderCardRadioGroup({'aria-label': 'test'}); + + await testA11y(container); + }); + + it('should have no a11y violations when description is provided', async () => { + const {container} = render( + +

description

+ Option A + Option B +
+ ); + + await testA11y(container); + }); + }); +}); diff --git a/src/components/card-interactive/CardRadioGroup.stories.mdx b/src/components/card-interactive/CardRadioGroup.stories.mdx new file mode 100644 index 0000000000..552bee92d7 --- /dev/null +++ b/src/components/card-interactive/CardRadioGroup.stories.mdx @@ -0,0 +1,604 @@ +import {ArgsTable, Meta, Story, Canvas} from '@storybook/addon-docs'; +import {styled} from '@storybook/theming'; +import {StoryVariantTable} from '../../docs/utils'; +import PageHeader from 'blocks/PageHeader'; + +import Flex from '../flex/Flex'; +import Text from '../text/Text'; +import Headline from '../text/Headline'; +import SubjectIcon from '../subject-icons/SubjectIcon'; + +import CardRadioGroup from './CardRadioGroup'; +import CardRadioGroupA11y from './stories/CardRadioGroup.a11y.mdx'; +import CardCustomisation from './stories/CardCustomisation.mdx'; + + + +CardRadioGroup + +- [Stories](#stories) +- [Accessibility](#accessibility) + +## Overview + + + ( +
+ +
+ ), + ]} + > + {args => ( + + + + + + Placeholder + + + + + )} +
+
+ + + +## Stories + +### Variants + +#### Solid + + + ( +
+ +
+ ), + ]} + > + {args => ( + + + + DARK + + + + + + + Anatomy + + + + + + + LIGHT + + + + +
+ +
+ + + Statistics + + +
+
+
+
+ )} +
+
+ +#### Outline + + + ( +
+ +
+ ), + ]} + > + {args => ( + + + + DARK + + + + + + + Tourism + + + + + + + LIGHT + {' '} + + + + + + Environment + + + + + + )} +
+
+ +### Indicator position + + + ( +
+ +
+ ), + ]} + > + {args => ( + + + + + + + + + + + Placeholder + + + + + )} +
+
+ +### States + +#### Dark + + + + {args => ( + + + + + + + default + + + + + disabled + + + + + error + + + + + + {['outline', 'solid'].map(variant => { + return [false, true].map(indicator => { + return [false, true].map(checked => { + const states = ['default', 'disabled', 'error'].map(states => ( + + + + {indicator && ( + + )} + + + Placeholder + + + + + + )); + return ( + + + + {variant} +
+ {checked ? 'checked' : 'unchecked'} +
+ {indicator + ? 'with indicator' + : 'without indicator'}{' '} +
+
+ + {states} + + ); + }); + }); + })} + +
+ )} +
+
+ +#### Light + + + ( +
+ +
+ ), + ]} + > + {args => ( + + + + + + + default + + + + + disabled + + + + + error + + + + + + {['outline', 'solid'].map(variant => { + return [false, true].map(indicator => { + return [false, true].map(checked => { + const states = ['default', 'disabled', 'error'].map(states => ( + + + + {indicator && ( + + )} + + + Placeholder + + + + + + )); + return ( + + + + {variant} +
+ {checked ? 'checked' : 'unchecked'} +
+ {indicator + ? 'with indicator' + : 'without indicator'}{' '} +
+
+ + {states} + + ); + }); + }); + })} + +
+ )} +
+
+ +### Examples + +#### Animated background + + + ( +
+ +
+ ), + ]} + > + {args => { + const Image = styled.div` + width: 105px; + height: 100%; + background-image: ${props => + props.src ? `url(${props.src})` : 'none'}; + background-size: 100%; + background-position: center; + transition: background-size 0.26s linear; + .sg-card-interactive:hover &, + .sg-card-interactive:focus-within & { + background-size: 108%; + } + `; + const ExtendedCardRadio = (value, label, image) => { + return ( + {}} + onMouseLeave={() => {}} + > + + + + {label} + + + + + ); + }; + return ( + + {ExtendedCardRadio('student', 'I’m a Student', 'avatar_10')} + {ExtendedCardRadio('parent', 'I’m a Parent', 'avatar_16')} + + ); + }} +
+
+ +## Customisation + + + +## Accessibility + + diff --git a/src/components/card-interactive/CardRadioGroup.tsx b/src/components/card-interactive/CardRadioGroup.tsx new file mode 100644 index 0000000000..526b6dafe3 --- /dev/null +++ b/src/components/card-interactive/CardRadioGroup.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import {generateId} from '../utils'; +import cx from 'classnames'; +import Flex from '../flex/Flex'; +import {CardRadioGroupContext} from './CardRadioGroupContext'; +import type {CardRadioGroupContextType} from './CardRadioGroupContext'; +import {CardRadio, CardRadioIndicator} from './CardRadio'; +import type {FlexPropsType} from '../flex/Flex'; + +export interface CardRadioGroupPropsType { + /** + * The name of the radio group and the form data when submitting the form. + */ + name?: string; + + /** + * Optional boolean. Whether the CardRadioGroup is required. + * @default false + */ + required?: boolean; + + /** + * Optional boolean. Whether the CardRadioGroup is disabled. + * @default false + */ + disabled?: boolean; + + /** + * Optional boolean. Whether the CardRadioGroup is invalid. + * @default false + **/ + invalid?: boolean; + + /** + * Optional string. The direction of the CardRadioGroup. + * @default 'row' + **/ + direction?: FlexPropsType['direction']; + + /** + * CardRadioGroup inner elements + * @example + * + * 1 + * 2 + * + * + **/ + children: React.ReactNode; + + /** + * Optional string. The className of the CardRadioGroup. + * @default undefined + **/ + className?: string; + + 'aria-labelledby'?: string; + 'aria-describedby'?: string; + 'aria-label'?: string; + + /** + * Optional string. The default value of the CardRadioGroup. + * @default '' + **/ + defaultValue?: string; + + /** + * Optional string. Currently selected value of the CardRadioGroup.Item. + **/ + value?: string; + + /** + * Optional function. The callback function that is triggered when the value of the CardRadioGroup changes. + **/ + onChange?: (value: string) => void; +} + +const CardRadioGroupRoot = React.forwardRef< + HTMLDivElement, + CardRadioGroupPropsType +>((props, ref) => { + const { + name, + required = false, + disabled = false, + invalid = false, + direction = 'row', + children, + className, + defaultValue = '', + value: controlledValue, + onChange, + ...other + } = props; + + const [internalValue, setInternalValue] = React.useState( + defaultValue + ); + + const isControlled = controlledValue !== undefined; + + const value = isControlled ? controlledValue : internalValue; + + const handleChange = (newValue: string) => { + if (!isControlled) { + setInternalValue(newValue); + } + + if (onChange) { + onChange(newValue); + } + }; + + const contextValue: CardRadioGroupContextType = { + name: name || `card-radio-group-${generateId()}`, + required, + disabled, + invalid, + value, + onChange: handleChange, + }; + + return ( + + + {children} + + + ); +}); + +const CardRadioGroup = Object.assign(CardRadioGroupRoot, { + Item: CardRadio, + Indicator: CardRadioIndicator, +}); + +CardRadioGroup.displayName = 'CardRadioGroup'; +CardRadio.displayName = 'CardRadioGroup.Item'; +CardRadioIndicator.displayName = 'CardRadioGroup.Indicator'; + +export default CardRadioGroup; diff --git a/src/components/card-interactive/CardRadioGroupContext.tsx b/src/components/card-interactive/CardRadioGroupContext.tsx new file mode 100644 index 0000000000..470bf00872 --- /dev/null +++ b/src/components/card-interactive/CardRadioGroupContext.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; + +export type CardRadioGroupContextType = { + name?: string; + required: boolean; + disabled: boolean; + invalid?: boolean; + value?: string; + onChange?(value: string): void; +}; + +export const CardRadioGroupContext = + React.createContext({ + required: false, + disabled: false, + }); + +export const useCardRadioGroupContext = () => { + const context = React.useContext(CardRadioGroupContext); + + if (!context) { + throw new Error('Component must be used within a CardRadioGroup'); + } + return context; +}; diff --git a/src/components/card-interactive/_card.scss b/src/components/card-interactive/_card.scss new file mode 100644 index 0000000000..a354640b21 --- /dev/null +++ b/src/components/card-interactive/_card.scss @@ -0,0 +1,256 @@ +.sg-card-interactive { + --card-background-color: var(--white); + --card-border-color: var(--gray-30); + --card-border-color-hover: var(--gray-40); + --card-border-color-invalid: var(--red-60); + --card-border-color-invalid-hover: var(--red-50); + --card-border-color-checked: var(--black); + --card-border-color-checked-hover: var(--gray-60); + --card-indicator-color: var(--white); + + --card-focus-color: var(--focusColor); + --card-focus-inner-color: var(--focusInnerColor); + --card-focus-outer-color: var(--focusOuterColor); + --card-focus-color-invalid: var(--red-40); + --card-focus-outer-color-invalid: rgba(207, 28, 0, 0.3); + + --card-border-width: 2px; + --card-border-radius: 8px; + --card-press-scale-factor: 0.97; + + &[data-color='light'] { + --card-background-color: var(--black); + --card-border-color: var(--gray-40); + --card-border-color-hover: var(--gray-50); + --card-border-color-invalid: var(--red-40); + --card-border-color-invalid-hover: var(--red-50); + --card-border-color-checked: var(--white); + --card-border-color-checked-hover: var(--white); + --card-indicator-color: var(--black); + + --card-focus-color: var(--focusColor); + --card-focus-inner-color: var(--black); + --card-focus-outer-color: var(--focusOuterColor); + --card-focus-color-invalid: var(--red-40); + --card-focus-outer-color-invalid: rgba(207, 28, 0, 0.3); + } + + display: block; + position: relative; + width: var(--card-width, auto); + height: var(--card-height, auto); + user-select: none; + + &__border { + display: block; + position: relative; + width: 100%; + height: 100%; + background: transparent; + border-radius: var(--card-border-radius); + overflow: hidden; + + transition: transform $durationModerate2 $easingRegular, + background-color $durationModerate1 $easingLinear, + background-image $durationModerate1 $easingLinear, + background-position $durationModerate1 $easingLinear; + } + + &__background { + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + position: absolute; + inset: var(--card-border-width); + border-radius: calc(var(--card-border-radius) - var(--card-border-width)); + overflow: hidden; + background-color: var(--card-background-color); + background-clip: padding-box; + } + + &[data-variant='outline'] &__border { + background: var(--card-border-color); + } + + &[data-variant='solid'] &__border { + background: transparent; + } + + &:active:not([data-disabled='true']) &__border { + transform: scale(var(--card-press-scale-factor)); + } + + &[data-disabled='true'] { + opacity: 0.45; + } + + &[data-checked='true'], + &[data-checked='mixed'] { + .sg-card-interactive__border { + background: var(--card-border-color-checked); + } + + & .sg-checkbox { + --checkboxColor: var(--card-border-color-checked); + --checkboxCheckedColor: var(--card-border-color-checked); + } + + & .sg-radio { + --radioColor: var(--card-border-color-checked); + } + } + + &[data-invalid='true'] { + .sg-card-interactive__border { + background: var(--card-border-color-invalid); + } + + & .sg-checkbox { + --checkboxColor: var(--card-border-color-invalid); + --checkboxCheckedColor: var(--card-border-color-invalid); + } + + & .sg-radio { + --radioColor: var(--card-border-color-invalid); + --radioHoverColor: var(--card-border-color-invalid); + } + } + + &:hover:not([data-disabled='true']) { + .sg-card-interactive__border { + background: var(--card-border-color-hover); + } + + & .sg-checkbox { + --checkboxColor: var(--card-border-color-hover); + --checkboxCheckedColor: var(--card-border-color-hover); + --checkboxHoverColor: var(--card-border-color-hover); + transition: all $durationModerate1 $easingRegular; + } + + & .sg-radio { + --radioColor: var(--card-border-color-hover); + --radioHoverColor: var(--card-border-color-hover); + } + + &[data-checked='true'], + &[data-checked='mixed'] { + .sg-card-interactive__border { + background: var(--card-border-color-checked-hover); + } + + & .sg-checkbox { + --checkboxColor: var(--card-border-color-checked-hover); + --checkboxCheckedColor: var(--card-border-color-checked-hover); + --checkboxHoverColor: var(--card-border-color-checked-hover); + } + + & .sg-radio { + --radioColor: var(--card-border-color-checked-hover); + --radioHoverColor: var(--card-border-color-checked-hover); + } + } + + &[data-invalid='true'] { + .sg-card-interactive__border { + background: var(--card-border-color-invalid-hover); + } + + & .sg-checkbox { + --checkboxColor: var(--card-border-color-invalid-hover); + --checkboxCheckedColor: var(--card-border-color-invalid-hover); + --checkboxHoverColor: var(--card-border-color-invalid-hover); + } + + & .sg-radio { + --radioColor: var(--card-border-color-invalid-hover); + --radioHoverColor: var(--card-border-color-invalid-hover); + } + } + } + + /* Indicators */ + & .sg-checkbox { + --checkboxColor: var(--card-border-color); + --checkboxCheckedColor: var(--card-border-color-checked); + --checkboxIconFillColor: var(--card-indicator-color); + } + + & .sg-checkbox__icon { + transition: all $durationModerate1 $easingLinear; + } + + & .sg-radio { + --radioColor: var(--card-border-color); + --radioRingInsideColor: var(--card-indicator-color); + } + + & .sg-radio__circle { + transition: all $durationModerate1 $easingLinear; + } + + /* Focus styles */ + + --focusOuterWidth: 3px; + + &[data-invalid='true'] + .sg-card-interactive__input:focus-visible + + .sg-card-interactive__border { + --focusColor: var(--card-focus-color-invalid); + --focusInnerColor: var(--card-focus-inner-color); + --focusOuterColor: var(--card-focus-outer-color-invalid); + @include applyFocusStyle(); + } + + & .sg-card-interactive__input:focus-visible + .sg-card-interactive__border { + --focusColor: var(--card-focus-color); + --focusInnerColor: var(--card-focus-inner-color); + --focusOuterColor: var(--card-focus-outer-color); + @include applyFocusStyle(); + } +} + +.sg-card-interactive__input { + position: absolute; + opacity: 0; + top: 0; + left: 0; +} + +.sg-card-interactive__indicator { + position: absolute; + width: 32px; + height: 32px; + + /* + The indicator is coming from the real component (checkbox or radio) so we need to remove its input from the layout + */ + & input { + display: none; + } + + &--top-left { + top: calc(var(--card-border-width) * -1); + left: calc(var(--card-border-width) * -1); + } + &--center-left { + top: 50%; + left: calc(var(--card-border-width) * -1); + transform: translateY(-50%); + } + &--bottom-left { + bottom: calc(var(--card-border-width) * -1); + left: calc(var(--card-border-width) * -1); + } + &--top-right { + top: calc(var(--card-border-width) * -1); + right: calc(var(--card-border-width) * -1); + } + &--center-right { + top: 50%; + right: calc(var(--card-border-width) * -1); + transform: translateY(-50%); + } + &--bottom-right { + bottom: calc(var(--card-border-width) * -1); + right: calc(var(--card-border-width) * -1); + } +} diff --git a/src/components/card-interactive/stories/CardCheckbox.a11y.mdx b/src/components/card-interactive/stories/CardCheckbox.a11y.mdx new file mode 100644 index 0000000000..0d2f7e2a38 --- /dev/null +++ b/src/components/card-interactive/stories/CardCheckbox.a11y.mdx @@ -0,0 +1,16 @@ +import rules from './CardCheckbox.rules.a11y'; + +### Rules + + + + + Safari does not fully support indeterminate state of checkbox. It + is a know bug reported in{' '} + + Webkit Bugzilla + + diff --git a/src/components/card-interactive/stories/CardCheckbox.rules.a11y.ts b/src/components/card-interactive/stories/CardCheckbox.rules.a11y.ts new file mode 100644 index 0000000000..8a0c19832d --- /dev/null +++ b/src/components/card-interactive/stories/CardCheckbox.rules.a11y.ts @@ -0,0 +1,67 @@ +const rules = [ + { + pattern: 'Should have a role checkbox.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Should be focusable and tabable.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Should have an accessible name.', + comment: `Can be named by:
    +
  • a label specified by aria-label prop
  • +
  • a value (IDREF) set for the aria-labelledby prop that refers to an element
  • +
  • can be named by adding children.
`, + status: 'DONE', + tests: 'DONE', + }, + { + pattern: + 'Should have visible checked / unchecked / indeterminate / disabled style.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: 'Should have visible focus and hover style.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: + 'Should have a label with 4.5:1 contrast ratio against the background.', + comment: + 'dark against white: 21:1, light against black: 20.9:1.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: + 'Should have a color indicator with 3:1 contrast ratio against the background.', + comment: + 'dark against white: 21:1, light against black: 20.9:1.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: 'Should respect prefers reduce motion settings.', + status: 'TO DO', + tests: 'N/A', + }, + { + pattern: + 'Should be activated by pressing Space and mouse click.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Can have a clickable label.', + comment: 'The whole area of card is wrapped with clickable label.', + status: 'DONE', + tests: 'DONE', + }, +]; + +export default rules; diff --git a/src/components/card-interactive/stories/CardCustomisation.mdx b/src/components/card-interactive/stories/CardCustomisation.mdx new file mode 100644 index 0000000000..719b0eaaab --- /dev/null +++ b/src/components/card-interactive/stories/CardCustomisation.mdx @@ -0,0 +1,20 @@ +Component supports following css variables which can be overwritten using custom class or directly in component's style prop. + +```css +'--card-background-color'?: React.CSSProperties[ 'backgroundColor' ]; +'--card-border-color'?: React.CSSProperties[ 'borderColor' ]; +'--card-border-color-hover'?: React.CSSProperties[ 'borderColor' ]; +'--card-border-color-invalid'?: React.CSSProperties[ 'borderColor' ]; +'--card-border-color-invalid-hover'?: React.CSSProperties[ 'borderColor' ]; +'--card-border-color-checked'?: React.CSSProperties[ 'borderColor' ]; +'--card-border-color-checked-hover'?: React.CSSProperties[ 'borderColor' ]; +'--card-indicator-color'?: React.CSSProperties[ 'color' ]; + +'--card-focus-color'?: React.CSSProperties[ 'color' ]; +'--card-focus-inner-color'?: React.CSSProperties[ 'color' ]; +'--card-focus-outer-color'?: React.CSSProperties[ 'color' ]; +'--card-focus-color-invalid'?: React.CSSProperties[ 'color' ]; +'--card-focus-outer-color-invalid'?: React.CSSProperties[ 'color' ]; + +'--card-press-scale-factor'?: number; +``` diff --git a/src/components/card-interactive/stories/CardRadioGroup.a11y.mdx b/src/components/card-interactive/stories/CardRadioGroup.a11y.mdx new file mode 100644 index 0000000000..08afb43a1d --- /dev/null +++ b/src/components/card-interactive/stories/CardRadioGroup.a11y.mdx @@ -0,0 +1,11 @@ +import {rules, radioItemRules} from './CardRadioGroup.rules.a11y'; + +### Rules + +#### CardRadioGroup + + + +#### CardRadioGroup.Item + + diff --git a/src/components/card-interactive/stories/CardRadioGroup.rules.a11y.ts b/src/components/card-interactive/stories/CardRadioGroup.rules.a11y.ts new file mode 100644 index 0000000000..0208ca74df --- /dev/null +++ b/src/components/card-interactive/stories/CardRadioGroup.rules.a11y.ts @@ -0,0 +1,131 @@ +export const rules = [ + { + pattern: 'Should have a role radiogroup.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Should have only one checked radio at a time.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Should have descriptive information about invalid state.', + comment: + 'State can be described using aria-description or aria-describedby.', + status: 'DONE', + tests: 'TO DO', + }, + { + pattern: 'Can have an accessible name.', + comment: `Can be named by:
    +
  • a label specified by aria-label prop
  • +
  • a value (IDREF) set for the aria-labelledby + prop that refers to an element.
`, + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Can provide information about active descendant.', + comment: `The aria-activedescendant attribute identifies the checked Radio + within Radiogroup by referencing the id value of the radio button that is active.`, + status: 'TO DO', + tests: 'TO DO', + }, + { + pattern: 'Can have an accessible description.', + comment: + 'Can be described by setting a value for aria-describedby prop.', + status: 'DONE', + tests: 'TO DO', + }, +]; + +export const radioItemRules = [ + { + pattern: 'Should have a role radio.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Should be focusable.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Should have an accessible name.', + comment: `Can be named by:
    +
  • a label specified by aria-label prop
  • +
  • a value (IDREF) set for the aria-labelledby prop that refers to an element
  • +
  • can be named by adding children.
`, + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Should have visible checked / unchecked / disabled style.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: 'Should have visible focus and hover style.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: + 'Should have a label with 4.5:1 contrast ratio against the background.', + comment: 'Selected color should relate to the label color.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: + 'Should have a color indicator with 3:1 contrast ratio against the background.', + comment: + 'dark against white: 21:1, light against black: 13.48:1.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: 'Should respect Windows High Contrast mode.', + status: 'TO DO', + tests: 'N/A', + }, + { + pattern: + 'Should have descriptive information about required and invalid state.', + comment: + 'Invalid state is indicated by color change. Both states can be described using aria-describedby prop.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Should respect prefers reduce motion settings.', + status: 'TO DO', + tests: 'N/A', + }, + { + pattern: 'Should be focused and checked by pressing arrows.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: 'Should be nested in a radiogroup ', + comment: 'CardRadioGroup component is required.', + status: 'DONE', + tests: 'N/A', + }, + { + pattern: 'Can have a clickable label.', + comment: 'The whole area of card is wrapped with clickable label.', + status: 'DONE', + tests: 'DONE', + }, + { + pattern: 'Can have an accessible description.', + comment: + 'Can be described by setting a value for aria-description prop.', + status: 'DONE', + tests: 'DONE', + }, +]; diff --git a/src/components/card-interactive/types.ts b/src/components/card-interactive/types.ts new file mode 100644 index 0000000000..16e2f6aa55 --- /dev/null +++ b/src/components/card-interactive/types.ts @@ -0,0 +1,18 @@ +export type StyleType = React.CSSProperties & { + '--card-background-color'?: React.CSSProperties['backgroundColor']; + '--card-border-color'?: React.CSSProperties['borderColor']; + '--card-border-color-hover'?: React.CSSProperties['borderColor']; + '--card-border-color-invalid'?: React.CSSProperties['borderColor']; + '--card-border-color-invalid-hover'?: React.CSSProperties['borderColor']; + '--card-border-color-checked'?: React.CSSProperties['borderColor']; + '--card-border-color-checked-hover'?: React.CSSProperties['borderColor']; + '--card-indicator-color'?: React.CSSProperties['color']; + + '--card-focus-color'?: React.CSSProperties['color']; + '--card-focus-inner-color'?: React.CSSProperties['color']; + '--card-focus-outer-color'?: React.CSSProperties['color']; + '--card-focus-color-invalid'?: React.CSSProperties['color']; + '--card-focus-outer-color-invalid'?: React.CSSProperties['color']; + + '--card-press-scale-factor'?: number; +}; diff --git a/src/index.ts b/src/index.ts index d8500a55fd..0bba14d0bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,3 +175,7 @@ export type {ProgressBarPropsType} from './components/progress-bar/ProgressBar'; export {default as ProgressBar} from './components/progress-bar/ProgressBar'; export type {SparksPropsType} from './components/sparks/Sparks'; export {default as Sparks} from './components/sparks/Sparks'; +export {default as CardRadioGroup} from './components/card-interactive/CardRadioGroup'; +export type {CardRadioGroupPropsType} from './components/card-interactive/CardRadioGroup'; +export {default as CardCheckbox} from './components/card-interactive/CardCheckbox'; +export type {CardCheckboxPropsType} from './components/card-interactive/CardCheckbox'; diff --git a/src/sass/main.scss b/src/sass/main.scss index d1c525a577..be4698287c 100644 --- a/src/sass/main.scss +++ b/src/sass/main.scss @@ -70,4 +70,5 @@ $sgFontsPath: 'fonts/' !default; @import '../components/select-menu/select-menu'; @import '../components/progress-bar/progress-bar'; @import '../components/sparks/sparks'; +@import '../components/card-interactive/card'; @import '../components/chip/chip'; diff --git a/yarn.lock b/yarn.lock index 8c338f6c05..1f6ee667be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4183,6 +4183,18 @@ resolved "https://registry.yarnpkg.com/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz#c06dac2d011f36d61259aa1c6df4f0d5e28bc55e" integrity sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg== +"@rive-app/canvas@1.0.102": + version "1.0.102" + resolved "https://registry.yarnpkg.com/@rive-app/canvas/-/canvas-1.0.102.tgz#e34dd1bba5411bc16cca282b20c8f8050cda18cb" + integrity sha512-EoJ+rNHh6ORn6wH1dKoI9trNddwSPkmYnGrVA7ShZwaDKl3LTDP8eWwSJp1wYmB2HE3dBjHZHQabQpFW8ohX7g== + +"@rive-app/react-canvas@^3.0.38": + version "3.0.38" + resolved "https://registry.yarnpkg.com/@rive-app/react-canvas/-/react-canvas-3.0.38.tgz#b9532666c2186bb75058cdf0f9c1bd75f367c610" + integrity sha512-V5+/I1ZJP416sp1uIQqm0kn9mA1fq1pNx1Il/f5AQYQw8Fu4P7Ume4+2HrMfWN1EHMvSqEgaBfsBUpFtYMQ1vw== + dependencies: + "@rive-app/canvas" "1.0.102" + "@rushstack/node-core-library@3.53.3": version "3.53.3" resolved "https://registry.yarnpkg.com/@rushstack/node-core-library/-/node-core-library-3.53.3.tgz#e78e0dc1545f6cd7d80b0408cf534aefc62fbbe2"