From c19055bd6453ce40ca43b31d599f14ec65d6037a Mon Sep 17 00:00:00 2001 From: Aram <37216945+alimpens@users.noreply.github.com> Date: Wed, 25 Sep 2024 16:10:06 +0200 Subject: [PATCH] feat!: Use inline SVG icon for Radio button (#1460) Co-authored-by: Vincent Smedinga --- packages/css/src/components/radio/radio.scss | 211 ++++++++++-------- packages/react/src/Radio/Radio.test.tsx | 15 +- packages/react/src/Radio/Radio.tsx | 9 +- proprietary/assets/icons/Radio.svg | 1 + proprietary/react-icons/.svgrrc.js | 1 + proprietary/react-icons/svgo.config.mjs | 15 ++ .../src/components/ams/radio.tokens.json | 71 +++--- storybook/src/components/Radio/CustomIcon.tsx | 14 ++ storybook/src/components/Radio/Radio.docs.mdx | 9 + .../src/components/Radio/Radio.stories.tsx | 10 + 10 files changed, 228 insertions(+), 128 deletions(-) create mode 100644 proprietary/assets/icons/Radio.svg create mode 100644 proprietary/react-icons/svgo.config.mjs create mode 100644 storybook/src/components/Radio/CustomIcon.tsx diff --git a/packages/css/src/components/radio/radio.scss b/packages/css/src/components/radio/radio.scss index 1aa6c4efe9..ddfe99957d 100644 --- a/packages/css/src/components/radio/radio.scss +++ b/packages/css/src/components/radio/radio.scss @@ -12,28 +12,7 @@ @include input-label-focus; } -.ams-radio__circle { - align-items: center; - block-size: calc(var(--ams-radio-font-size) * var(--ams-radio-line-height)); - display: flex; - flex-shrink: 0; - inline-size: 1.5rem; - - &::after { - background-position: center; - background-repeat: no-repeat; - background-size: 1rem; - block-size: 1.5rem; - border-color: var(--ams-radio-circle-border-color); - border-radius: 100%; - border-style: solid; - border-width: var(--ams-radio-circle-border-width); - box-sizing: border-box; - content: ""; - inline-size: 100%; - } -} - +// Default .ams-radio__label { color: var(--ams-radio-color); cursor: pointer; @@ -44,111 +23,165 @@ gap: var(--ams-radio-gap); line-height: var(--ams-radio-line-height); outline-offset: var(--ams-radio-outline-offset); + text-decoration-thickness: var(--ams-radio-text-decoration-thickness); + text-underline-offset: var(--ams-radio-text-underline-offset); @include text-rendering; +} - &:hover { - color: var(--ams-radio-hover-color); - text-decoration-line: underline; - text-decoration-thickness: var(--ams-radio-hover-text-decoration-thickness); - text-underline-offset: 0.375rem; +.ams-radio__icon-container { + block-size: var(--ams-radio-icon-container-block-size); + display: flex; + flex: none; + inline-size: var(--ams-radio-icon-container-inline-size); +} - .ams-radio__circle::after { - border-color: var(--ams-radio-circle-hover-border-color); - } +.ams-radio__circle { + fill: none; + stroke: var(--ams-radio-circle-stroke); + stroke-width: 0.125rem; +} + +.ams-radio__checked-indicator { + display: none; + fill: var(--ams-radio-checked-indicator-fill); +} + +// Default hover +.ams-radio__label:hover { + color: var(--ams-radio-hover-color); + text-decoration-line: var(--ams-radio-hover-text-decoration-line); + + .ams-radio__circle { + stroke: var(--ams-radio-circle-hover-stroke); + } + + .ams-radio__checked-indicator { + fill: var(--ams-radio-checked-indicator-hover-fill); } } -// Default checked -.ams-radio__input:checked { - + .ams-radio__label .ams-radio__circle::after { - background-image: var(--ams-radio-circle-checked-background-image); +// Invalid +.ams-radio__input[aria-invalid="true"] + .ams-radio__label { + .ams-radio__circle { + stroke: var(--ams-radio-circle-invalid-stroke); + } + + .ams-radio__checked-indicator { + fill: var(--ams-radio-checked-indicator-invalid-fill); } } -// Invalid unchecked -.ams-radio__input[aria-invalid="true"] { - + .ams-radio__label .ams-radio__circle::after { - border-color: var(--ams-radio-circle-invalid-border-color); +// Checked +.ams-radio__input:checked + .ams-radio__label { + .ams-radio__checked-indicator { + display: block; } } -// Disabled unchecked -.ams-radio__input:disabled { - + .ams-radio__label { - color: var(--ams-radio-disabled-color); - cursor: not-allowed; +// Disabled +.ams-radio__input:disabled + .ams-radio__label { + color: var(--ams-radio-disabled-color); + cursor: not-allowed; - .ams-radio__circle::after { - border-color: var(--ams-radio-circle-disabled-border-color); - border-width: var(--ams-radio-circle-disabled-border-width); - } + .ams-radio__circle { + stroke: var(--ams-radio-circle-disabled-stroke); + } + + .ams-radio__checked-indicator { + fill: var(--ams-radio-checked-indicator-disabled-fill); } } -// Invalid checked -.ams-radio__input[aria-invalid="true"]:checked { - + .ams-radio__label .ams-radio__circle::after { - background-image: var(--ams-radio-circle-invalid-checked-background-image); +// Disabled invalid +.ams-radio__input[aria-invalid="true"]:disabled + .ams-radio__label { + .ams-radio__circle { + // TODO: currently disabled invalid gets the same styling as disabled. This should get its own styling. + stroke: var(--ams-radio-circle-disabled-invalid-stroke); + } + + .ams-radio__checked-indicator { + // TODO: currently disabled invalid gets the same styling as disabled. This should get its own styling. + fill: var(--ams-radio-checked-indicator-disabled-invalid-fill); } } -// Disabled label +// HOVER + +// Disabled label hover .ams-radio__input:disabled + .ams-radio__label:hover { text-decoration: none; } -// Disabled checked -.ams-radio__input:disabled:checked { - + .ams-radio__label .ams-radio__circle::after { - background-image: var(--ams-radio-circle-disabled-checked-background-image); +// Invalid hover +.ams-radio__input[aria-invalid="true"] + .ams-radio__label:hover { + .ams-radio__circle { + // TODO: this should be the (currently non-existent) dark red hover color + stroke: var(--ams-radio-circle-invalid-hover-stroke); } -} -// Disabled invalid unchecked -.ams-radio__input[aria-invalid="true"]:disabled { - + .ams-radio__label .ams-radio__circle::after { - // TODO: currently disabled invalid gets the same styling as disabled. This should get its own styling. - border-color: var(--ams-radio-circle-disabled-border-color); + .ams-radio__checked-indicator { + // TODO: this should be the (currently non-existent) dark red hover color + fill: var(--ams-radio-checked-indicator-invalid-hover-fill); } } -// HOVER STATES +// Disabled invalid hover +.ams-radio__input[aria-invalid="true"]:disabled + .ams-radio__label:hover { + .ams-radio__circle { + // TODO: currently disabled invalid gets the same styling as disabled. This should get its own styling. + stroke: var(--ams-radio-circle-disabled-invalid-hover-stroke); + } -// Invalid unchecked hover -.ams-radio__input[aria-invalid="true"] + .ams-radio__label:hover .ams-radio__circle::after { - // TODO: this should be the (currently non-existent) dark red hover color - border-color: var(--ams-radio-circle-invalid-hover-border-color); + .ams-radio__checked-indicator { + // TODO: currently disabled invalid gets the same styling as disabled. This should get its own styling. + fill: var(--ams-radio-checked-indicator-disabled-invalid-hover-fill); + } } -// Default checked hover -.ams-radio__input:checked + .ams-radio__label:hover .ams-radio__circle::after { - background-image: var(--ams-radio-circle-checked-hover-background-image); -} +// FORCED COLORS -// Invalid checked hover -.ams-radio__input[aria-invalid="true"]:checked + .ams-radio__label:hover .ams-radio__circle::after { - // TODO: this should be the (currently non-existent) dark red hover color - background-image: var(--ams-radio-circle-invalid-checked-hover-background-image); -} +// Default +@media (forced-colors: active) { + .ams-radio__label, + .ams-radio__label:hover, + .ams-radio__input[aria-invalid="true"] + .ams-radio__label, + .ams-radio__input[aria-invalid="true"] + .ams-radio__label:hover { + .ams-radio__circle { + stroke: FieldText; + } -// Disabled checked hover -.ams-radio__input:disabled:checked + .ams-radio__label:hover .ams-radio__circle::after { - background-image: var(--ams-radio-circle-disabled-checked-hover-background-image); + .ams-radio__checked-indicator { + fill: FieldText; + } + } } -// Disabled invalid unchecked hover -.ams-radio__input[aria-invalid="true"]:disabled + .ams-radio__label:hover .ams-radio__circle::after { - // TODO: currently disabled invalid gets the same styling as disabled. This should get its own styling. - border-color: var(--ams-radio-circle-disabled-border-color); +// Checked +@media (forced-colors: active) { + .ams-radio__input:checked + .ams-radio__label, + .ams-radio__input[aria-invalid="true"]:checked + .ams-radio__label:hover { + .ams-radio__circle { + stroke: ActiveText; + } + + .ams-radio__checked-indicator { + fill: ActiveText; + } + } } -// DISABLED INVALID STATES +// Disabled +@media (forced-colors: active) { + .ams-radio__input:disabled + .ams-radio__label, + .ams-radio__input[aria-invalid="true"]:disabled + .ams-radio__label, + .ams-radio__input[aria-invalid="true"]:disabled + .ams-radio__label:hover { + .ams-radio__circle { + stroke: GrayText; + } -// Disabled invalid checked -.ams-radio__input[aria-invalid="true"]:disabled:checked { - + .ams-radio__label .ams-radio__circle::after { - // TODO: currently disabled invalid gets the same styling as disabled. This should get its own styling. - background-image: var(--ams-radio-circle-disabled-checked-background-image); + .ams-radio__checked-indicator { + fill: GrayText; + } } } diff --git a/packages/react/src/Radio/Radio.test.tsx b/packages/react/src/Radio/Radio.test.tsx index 74b3168694..3100d2fa82 100644 --- a/packages/react/src/Radio/Radio.test.tsx +++ b/packages/react/src/Radio/Radio.test.tsx @@ -1,3 +1,4 @@ +import { FavouriteIcon } from '@amsterdam/design-system-react-icons' import { render, screen } from '@testing-library/react' import { createRef } from 'react' import { Radio } from './Radio' @@ -19,16 +20,20 @@ describe('Radio', () => { expect(label).toBeVisible() }) - it('renders a design system BEM class name', () => { + it('renders design system BEM class names', () => { const { container } = render() const wrapper = container.querySelector(':only-child') const input = screen.getByRole('radio') const label = container.querySelector('label') + const circle = container.querySelector('.ams-radio__circle') + const indicator = container.querySelector('.ams-radio__checked-indicator') expect(wrapper).toHaveClass('ams-radio') expect(input).toHaveClass('ams-radio__input') expect(label).toHaveClass('ams-radio__label') + expect(circle).toBeInTheDocument() + expect(indicator).toBeInTheDocument() }) it('renders an additional class name', () => { @@ -150,6 +155,14 @@ describe('Radio', () => { expect(handleChange).toHaveBeenCalled() }) + it('shows a custom icon', () => { + const { container } = render(} />) + + const icon = container.querySelector('svg') + + expect(icon).toHaveClass('test-class') + }) + it('supports ForwardRef in React', () => { const ref = createRef() diff --git a/packages/react/src/Radio/Radio.tsx b/packages/react/src/Radio/Radio.tsx index 67893ff72d..fcb1ca03bb 100644 --- a/packages/react/src/Radio/Radio.tsx +++ b/packages/react/src/Radio/Radio.tsx @@ -3,17 +3,20 @@ * Copyright Gemeente Amsterdam */ +import { RadioIcon } from '@amsterdam/design-system-react-icons' import clsx from 'clsx' import { forwardRef, useId } from 'react' -import type { ForwardedRef, InputHTMLAttributes, PropsWithChildren } from 'react' +import type { ForwardedRef, InputHTMLAttributes, PropsWithChildren, ReactNode } from 'react' export type RadioProps = { + /** An icon to display instead of the default icon. */ + icon?: ReactNode /** Whether the value fails a validation rule. */ invalid?: boolean } & PropsWithChildren, 'aria-invalid' | 'type'>> export const Radio = forwardRef( - ({ children, className, invalid, ...restProps }: RadioProps, ref: ForwardedRef) => { + ({ children, className, icon, invalid, ...restProps }: RadioProps, ref: ForwardedRef) => { const id = useId() return ( @@ -29,7 +32,7 @@ export const Radio = forwardRef( type="radio" /> diff --git a/proprietary/assets/icons/Radio.svg b/proprietary/assets/icons/Radio.svg new file mode 100644 index 0000000000..2df6ab053e --- /dev/null +++ b/proprietary/assets/icons/Radio.svg @@ -0,0 +1 @@ + diff --git a/proprietary/react-icons/.svgrrc.js b/proprietary/react-icons/.svgrrc.js index 6a26334fbf..60fa2dc525 100644 --- a/proprietary/react-icons/.svgrrc.js +++ b/proprietary/react-icons/.svgrrc.js @@ -7,4 +7,5 @@ module.exports = { focusable: 'false', }, typescript: true, + svgoConfig: './svgo.config.mjs', } diff --git a/proprietary/react-icons/svgo.config.mjs b/proprietary/react-icons/svgo.config.mjs new file mode 100644 index 0000000000..ffa2678e86 --- /dev/null +++ b/proprietary/react-icons/svgo.config.mjs @@ -0,0 +1,15 @@ +export default { + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + prefixIds: { + // Do not change class names defined in the SVGs + prefixClassNames: false, + }, + }, + }, + }, + ], +} diff --git a/proprietary/tokens/src/components/ams/radio.tokens.json b/proprietary/tokens/src/components/ams/radio.tokens.json index f704b39a77..3fe1016855 100644 --- a/proprietary/tokens/src/components/ams/radio.tokens.json +++ b/proprietary/tokens/src/components/ams/radio.tokens.json @@ -8,50 +8,47 @@ "gap": { "value": "{ams.space.sm}" }, "line-height": { "value": "{ams.text.level.5.line-height}" }, "outline-offset": { "value": "{ams.focus.outline-offset}" }, - "circle": { - "border-color": { "value": "{ams.color.primary-blue}" }, - "border-width": { "value": "{ams.border.width.md}" }, - "checked": { - "background-image": { - "value": "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' fill='%23004699'%3E%3Ccircle cx='16' cy='16' r='16' /%3E%3C/svg%3E\")" - }, + "text-decoration-thickness": { "value": "{ams.link-appearance.text-decoration-thickness}" }, + "text-underline-offset": { "value": "{ams.link-appearance.text-underline-offset}" }, + "checked-indicator": { + "fill": { "value": "{ams.color.primary-blue}" }, + "disabled": { + "fill": { "value": "{ams.color.neutral-grey3}" } + }, + "disabled-invalid": { + "fill": { "value": "{ams.color.neutral-grey3}" }, "hover": { - "background-image": { - "value": "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' fill='%23102E62'%3E%3Ccircle cx='16' cy='16' r='16' /%3E%3C/svg%3E\")" - } + "fill": { "value": "{ams.color.neutral-grey3}" } } }, + "hover": { + "fill": { "value": "{ams.color.dark-blue}" } + }, + "invalid": { + "fill": { "value": "{ams.color.primary-red}" }, + "hover": { + "fill": { "value": "{ams.color.primary-red}" } + } + } + }, + "circle": { + "stroke": { "value": "{ams.color.primary-blue}" }, "disabled": { - "border-color": { "value": "{ams.color.neutral-grey3}" }, - "border-width": { "value": "{ams.border.width.md}" }, - "checked": { - "background-image": { - "value": "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' fill='%23767676'%3E%3Ccircle cx='16' cy='16' r='16' /%3E%3C/svg%3E\")" - }, - "hover": { - "background-image": { - "value": "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' fill='%23767676'%3E%3Ccircle cx='16' cy='16' r='16' /%3E%3C/svg%3E\")" - } - } + "stroke": { "value": "{ams.color.neutral-grey3}" } + }, + "disabled-invalid": { + "stroke": { "value": "{ams.color.neutral-grey3}" }, + "hover": { + "stroke": { "value": "{ams.color.neutral-grey3}" } } }, "hover": { - "border-color": { "value": "{ams.color.dark-blue}" } + "stroke": { "value": "{ams.color.dark-blue}" } }, "invalid": { - "border-color": { "value": "{ams.color.primary-red}" }, - "checked": { - "background-image": { - "value": "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' fill='%23EC0000'%3E%3Ccircle cx='16' cy='16' r='16' /%3E%3C/svg%3E\")" - }, - "hover": { - "background-image": { - "value": "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' fill='%23EC0000'%3E%3Ccircle cx='16' cy='16' r='16' /%3E%3C/svg%3E\")" - } - } - }, + "stroke": { "value": "{ams.color.primary-red}" }, "hover": { - "border-color": { "value": "{ams.color.primary-red}" } + "stroke": { "value": "{ams.color.primary-red}" } } } }, @@ -60,7 +57,11 @@ }, "hover": { "color": { "value": "{ams.color.dark-blue}" }, - "text-decoration-thickness": { "value": "{ams.border.width.md}" } + "text-decoration-line": { "value": "{ams.link-appearance.subtle.hover.text-decoration-line}" } + }, + "icon-container": { + "block-size": { "value": "calc({ams.radio.font-size} * {ams.radio.line-height})" }, + "inline-size": { "value": "1.5rem" } } } } diff --git a/storybook/src/components/Radio/CustomIcon.tsx b/storybook/src/components/Radio/CustomIcon.tsx new file mode 100644 index 0000000000..14b725e54b --- /dev/null +++ b/storybook/src/components/Radio/CustomIcon.tsx @@ -0,0 +1,14 @@ +import type { SVGProps } from 'react' +const CustomIcon = (props: SVGProps) => ( + +) +export default CustomIcon diff --git a/storybook/src/components/Radio/Radio.docs.mdx b/storybook/src/components/Radio/Radio.docs.mdx index 75ee926d65..d48f68ae60 100644 --- a/storybook/src/components/Radio/Radio.docs.mdx +++ b/storybook/src/components/Radio/Radio.docs.mdx @@ -35,3 +35,12 @@ If the Radio can become invalid, add an Error Message component and its `id` to Check [the Field Set docs](/docs/components-forms-field-set--docs) for more information on configuring it. + +### Custom icons + +It is possible to change the default Radio icon. +This is intended for users who do not work within the City of Amsterdam corporate identity. +If you work for the City of Amsterdam, it is unlikely that you need this. +If you would like to use a custom icon, please contact designsystem@amsterdam.nl. + + diff --git a/storybook/src/components/Radio/Radio.stories.tsx b/storybook/src/components/Radio/Radio.stories.tsx index 4c1174ce06..f50abb9a39 100644 --- a/storybook/src/components/Radio/Radio.stories.tsx +++ b/storybook/src/components/Radio/Radio.stories.tsx @@ -7,6 +7,7 @@ import { Column, ErrorMessage, FieldSet, Paragraph } from '@amsterdam/design-sys import { Radio } from '@amsterdam/design-system-react/src' import { useArgs } from '@storybook/preview-api' import { Meta, StoryObj } from '@storybook/react' +import CustomIcon from './CustomIcon' const meta = { title: 'Components/Forms/Radio', @@ -28,6 +29,9 @@ const meta = { disabled: { description: 'Prevents interaction. Avoid if possible.', }, + icon: { + table: { disable: true }, + }, invalid: { description: 'Whether the value fails a validation rule.', }, @@ -145,3 +149,9 @@ export const InAFieldSetWithValidation: Story = { ), } + +export const CustomIcons: Story = { + args: { + icon: , + }, +}