From b3586c84ffa877e2e9de1b3397af50b764f0d2d2 Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Tue, 4 Aug 2026 11:34:15 +0200 Subject: [PATCH 1/7] [UIK-5640][radio-cards] new component/stories/unit tests --- pnpm-lock.yaml | 16 ++ semcore/radio-cards/.npmignore | 5 + semcore/radio-cards/CHANGELOG.md | 0 semcore/radio-cards/README.md | 37 ++++ semcore/radio-cards/__tests__/index.test.tsx | 92 +++++++++ semcore/radio-cards/package.json | 32 +++ semcore/radio-cards/src/RadioCards.tsx | 182 ++++++++++++++++++ semcore/radio-cards/src/RadioCards.type.ts | 66 +++++++ semcore/radio-cards/src/index.ts | 2 + .../src/styles/radio-cards.shadow.css | 35 ++++ semcore/radio-cards/tsconfig.json | 7 + semcore/radio-cards/vite.config.ts | 17 ++ semcore/radio-cards/vitest.config.mts | 17 ++ .../radio-cards/docs/examples/advanced.tsx | 49 +++++ .../radio-cards/docs/examples/basic.tsx | 15 ++ .../radio-cards/docs/radio-cards.stories.tsx | 20 ++ 16 files changed, 592 insertions(+) create mode 100644 semcore/radio-cards/.npmignore create mode 100644 semcore/radio-cards/CHANGELOG.md create mode 100644 semcore/radio-cards/README.md create mode 100644 semcore/radio-cards/__tests__/index.test.tsx create mode 100644 semcore/radio-cards/package.json create mode 100644 semcore/radio-cards/src/RadioCards.tsx create mode 100644 semcore/radio-cards/src/RadioCards.type.ts create mode 100644 semcore/radio-cards/src/index.ts create mode 100644 semcore/radio-cards/src/styles/radio-cards.shadow.css create mode 100644 semcore/radio-cards/tsconfig.json create mode 100644 semcore/radio-cards/vite.config.ts create mode 100644 semcore/radio-cards/vitest.config.mts create mode 100644 stories/components/radio-cards/docs/examples/advanced.tsx create mode 100644 stories/components/radio-cards/docs/examples/basic.tsx create mode 100644 stories/components/radio-cards/docs/radio-cards.stories.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6c122843e..7d039e4916 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1568,6 +1568,22 @@ importers: specifier: workspace:* version: link:../../tools/testing-utils + semcore/radio-cards: + dependencies: + '@semcore/typography': + specifier: ^17.2.1 + version: link:../typography + devDependencies: + '@semcore/base-components': + specifier: workspace:* + version: link:../base-components + '@semcore/core': + specifier: workspace:* + version: link:../core + '@semcore/testing-utils': + specifier: workspace:* + version: link:../../tools/testing-utils + semcore/select: dependencies: '@semcore/base-trigger': diff --git a/semcore/radio-cards/.npmignore b/semcore/radio-cards/.npmignore new file mode 100644 index 0000000000..036da2936b --- /dev/null +++ b/semcore/radio-cards/.npmignore @@ -0,0 +1,5 @@ +node_modules +__tests__ +src +tsconfig.json +tsconfig.tsbuildinfo diff --git a/semcore/radio-cards/CHANGELOG.md b/semcore/radio-cards/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/semcore/radio-cards/README.md b/semcore/radio-cards/README.md new file mode 100644 index 0000000000..08de0012d7 --- /dev/null +++ b/semcore/radio-cards/README.md @@ -0,0 +1,37 @@ +# @semcore/radio-cards + +[![version](https://img.shields.io/npm/v/@semcore/radio-cards.svg)](https://www.npmjs.com/@semcore/radio-cards) +[![downloads](https://img.shields.io/npm/dt/@semcore/radio-cards.svg)](https://www.npmjs.com/package/@semcore/radio-cards) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/semrush/intergalactic/blob/HEAD/LICENSE) + +> This component is part of the Intergalactic Design System + +### πŸ“– [Component documentation](https://developer.semrush.com/intergalactic/components/radio-cards/) + +### 🏠 [Design system](https://developer.semrush.com/intergalactic/) + +## Install + +```sh +npm install @semcore/radio-cards +``` + + + +## πŸ‘€ Author + +[UI-kit team](https://github.com/semrush/intergalactic/blob/HEAD/MAINTAINERS) and [others ❀️](https://github.com/semrush/intergalactic/graphs/contributors) + +## 🀝 Contributing + +Contributions, issues and feature requests are welcome! + +Feel free to check [issues page](https://github.com/semrush/intergalactic/issues). You can also take a look at the [contributing guide](https://github.com/semrush/intergalactic/blob/HEAD/CONTRIBUTING.md). + +## Show your support + +Give a ⭐️ if this project helped you! + +## πŸ“ License + +This project is [MIT](https://github.com/semrush/intergalactic/blob/HEAD/LICENSE) licensed. diff --git a/semcore/radio-cards/__tests__/index.test.tsx b/semcore/radio-cards/__tests__/index.test.tsx new file mode 100644 index 0000000000..c67a10c0a2 --- /dev/null +++ b/semcore/radio-cards/__tests__/index.test.tsx @@ -0,0 +1,92 @@ +import { runDependencyCheckTests } from '@semcore/testing-utils/shared-tests'; +import { render, cleanup, userEvent } from '@semcore/testing-utils/testing-library'; +import { expect, test, describe, beforeEach, vi } from '@semcore/testing-utils/vitest'; +import React from 'react'; + +import RadioCards from '../src'; + +describe('pills Dependency imports', () => { + runDependencyCheckTests('radio-cards'); +}); + +describe('RadioCards', () => { + beforeEach(cleanup); + + test('calls onChange with selected value', async () => { + const onChange = vi.fn(); + + const { getByRole } = render( + + 1 + 2 + 4 + , + ); + + await userEvent.click(getByRole('radio', { name: '4' })); + + expect(onChange).toHaveBeenCalledWith( + '4', + expect.any(Object), + ); + }); + + test('clicks aren\'t propagated on disabled tab', async () => { + const spy = vi.fn(); + + const { getByRole } = render( + + 1 + 2 + 3 + 4 + , + ); + + await expect(userEvent.click(getByRole('radio', { name: '4' }))).rejects.toThrow('pointer-events: none'); + expect(spy).not.toHaveBeenCalled(); + }); + + test('does not call onChange when group is disabled', async () => { + const spy = vi.fn(); + + const { getByRole } = render( + + 1 + 2 + , + ); + + await expect(userEvent.click(getByRole('radio', { name: '2' }))).rejects.toThrow('pointer-events: none'); + expect(spy).not.toHaveBeenCalled(); + }); + + test('supports keyboard navigation with ArrowRight', async () => { + const spy = vi.fn(); + + const { getByRole } = render( + + 1 + 2 + 3 + , + ); + + const firstRadio = getByRole('radio', { name: '1' }); + + firstRadio.focus(); + expect(firstRadio).toHaveFocus(); + + await userEvent.keyboard('{ArrowRight}'); + + expect(spy).toHaveBeenCalledWith( + '2', + expect.any(Object), + ); + }); +}); diff --git a/semcore/radio-cards/package.json b/semcore/radio-cards/package.json new file mode 100644 index 0000000000..c5f86566ca --- /dev/null +++ b/semcore/radio-cards/package.json @@ -0,0 +1,32 @@ +{ + "name": "@semcore/radio-cards", + "description": "Semrush Radio Cards Component", + "version": "17.2.1", + "main": "lib/cjs/index.js", + "module": "lib/es6/index.js", + "typings": "lib/types/index.d.ts", + "sideEffects": false, + "author": "UI-kit team ", + "license": "MIT", + "scripts": { + "build": "pnpm semcore-builder && pnpm vite build" + }, + "exports": { + "types": "./lib/types/index.d.ts", + "import": "./lib/esm/index.mjs", + "require": "./lib/cjs/index.js" + }, + "dependencies": { + "@semcore/typography": "^17.2.1" + }, + "repository": { + "type": "git", + "url": "https://github.com/semrush/intergalactic.git", + "directory": "semcore/radio-cards" + }, + "devDependencies": { + "@semcore/core": "workspace:*", + "@semcore/base-components": "workspace:*", + "@semcore/testing-utils": "workspace:*" + } +} diff --git a/semcore/radio-cards/src/RadioCards.tsx b/semcore/radio-cards/src/RadioCards.tsx new file mode 100644 index 0000000000..24c36d45ea --- /dev/null +++ b/semcore/radio-cards/src/RadioCards.tsx @@ -0,0 +1,182 @@ +import { Flex, NeighborLocation } from '@semcore/base-components'; +import type { Intergalactic } from '@semcore/core'; +import { Root, Component, createComponent, sstyled } from '@semcore/core'; +import a11yEnhance from '@semcore/core/lib/utils/enhances/a11yEnhance'; +import { isAdvanceMode } from '@semcore/core/lib/utils/findComponent'; +import { Text as SemcoreText } from '@semcore/typography'; +import React from 'react'; + +import type { NSRadioCards } from './RadioCards.type'; +import style from './styles/radio-cards.shadow.css'; + +class RadioCardsRoot extends Component< + Intergalactic.InternalTypings.InferComponentProps, + typeof RadioCardsRoot.enhance, + NSRadioCards.Handlers, + {}, + {}, + NSRadioCards.DefaultProps +> { + static displayName = 'RadioCards'; + static style = style; + + static enhance = [a11yEnhance({ + onNeighborChange: (neighborElement) => { + if (neighborElement) { + neighborElement.focus(); + neighborElement.click(); + } + }, + childSelector: () => { + const selector: [string, string] = ['role', 'radio']; + + return selector; + }, + })] as const; + + static defaultProps = { + defaultValue: '', + } as const; + + uncontrolledProps() { + return { + value: '', + }; + } + + handleClick = (value: NSRadioCards.Value) => (e: React.MouseEvent) => { + this.handlers.value(value, e); + }; + + handleKeyDown = (value: NSRadioCards.Value) => (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + + this.handlers.value(value, e); + } + }; + + getItemProps({ value: itemValue }: NSRadioCards.Item.Props) { + const { value, disabled } = this.asProps; + const isChecked = value === itemValue; + + return { + tabIndex: isChecked ? 0 : -1, + checked: isChecked, + disabled, + onClick: this.handleClick(itemValue), + onKeyDown: this.handleClick(itemValue), + }; + } + + render() { + const SRadioCards = Root; + const { Children, styles, value, disabled } = this.asProps; + + return sstyled(styles)( + + + + + , + ); + } +} + +function Item( + props: Intergalactic.InternalTypings.InferChildComponentProps< + NSRadioCards.Item.Component, + typeof RadioCardsRoot, + 'Item' + >, +) { + const SRadioItem = Root; + const { Children, styles, iconAddon, value, textAddon, description, disabled, checked } = props; + + const advancedMode = isAdvanceMode( + Children, + [RadioCards.Item.Header.displayName, RadioCards.Item.Description.displayName], + true, + ); + + return sstyled(styles)( + + {advancedMode + ? ( + + ) + : ( + <> + + {iconAddon && {iconAddon}} + {value && {value}} + {textAddon && ( + + + {textAddon} + + + )} + + {description && {description}} + + )} + , + ); +} + +function Header(props: Intergalactic.InternalTypings.InferComponentProps) { + const SRadioItemHeader = Root; + const { styles } = props; + + return sstyled(styles)(); +} + +function LeftAddon() { + const SItemHeaderAddon = Root; + + return ; +} + +function RightAddon() { + const SItemHeaderAddon = Root; + + return ; +} + +function Text() { + const SItemHeaderText = Root; + + return ; +} + +function Description() { + const SItemDescription = Root; + + return ; +} + +const RadioCards = createComponent(RadioCardsRoot, { + Item: [ + Item, + { + Header: [ + Header, + { + LeftAddon, + Text, + RightAddon, + }, + ], + Description, + }, + ], +}); + +export default RadioCards; diff --git a/semcore/radio-cards/src/RadioCards.type.ts b/semcore/radio-cards/src/RadioCards.type.ts new file mode 100644 index 0000000000..737e52c788 --- /dev/null +++ b/semcore/radio-cards/src/RadioCards.type.ts @@ -0,0 +1,66 @@ +import type { NSFlex } from '@semcore/base-components'; +import type { Intergalactic } from '@semcore/core'; +import type { NSText } from '@semcore/typography'; + +declare namespace NSRadioCards { + type Value = string; + type Props = NSFlex.Props & Intergalactic.RequireAtLeastOne<{ + 'aria-label'?: string; + 'aria-labelledby'?: string; + }> & { + value?: NSRadioCards.Value; + onChange?: (value: NSRadioCards.Value, event?: React.SyntheticEvent) => void; + disabled?: boolean; + }; + type DefaultProps = { + defaultValue: ''; + }; + type Handlers = { + value: NSRadioCards.Value; + }; + + namespace Item { + type Props = NSFlex.Props & { + disabled?: boolean; + iconAddon?: React.ReactNode; + value: NSRadioCards.Value; + textAddon?: string; + description?: string; + }; + + namespace Header { + namespace LeftAddon { + type Component = NSFlex.Component; + } + + namespace Text { + type Component = NSText.Component; + } + + namespace RightAddon { + type Component = NSFlex.Component; + } + + type Component = NSFlex.Component & { + LeftAddon: LeftAddon.Component; + Text: Text.Component; + RightAddon: RightAddon.Component; + }; + } + + namespace Description { + type Component = NSText.Component; + } + + type Component = Intergalactic.Component<'div', Props> & { + Header: Header.Component; + Description: Description.Component; + }; + } + + type Component = Intergalactic.Component<'div', Props> & { + Item: Item.Component; + }; +} + +export type { NSRadioCards }; diff --git a/semcore/radio-cards/src/index.ts b/semcore/radio-cards/src/index.ts new file mode 100644 index 0000000000..6fb1a9626e --- /dev/null +++ b/semcore/radio-cards/src/index.ts @@ -0,0 +1,2 @@ +export { default } from './RadioCards'; +export * from './RadioCards.type'; diff --git a/semcore/radio-cards/src/styles/radio-cards.shadow.css b/semcore/radio-cards/src/styles/radio-cards.shadow.css new file mode 100644 index 0000000000..d0aa7f8e0f --- /dev/null +++ b/semcore/radio-cards/src/styles/radio-cards.shadow.css @@ -0,0 +1,35 @@ +SRadioCards { + gap: var(--intergalactic-spacing-content-gap-large); +} + +SRadioItem { + flex-direction: column; + flex: 1 0 0; + gap: var(--intergalactic-spacing-content-gap-small); + padding: var(--intergalactic-spacing-3x) var(--intergalactic-spacing-5x); + align-items: flex-start; + + border-radius: var(--intergalactic-pills-rounded); + background: var(--intergalactic-control-presets-bg); + box-shadow: var(--intergalactic-box-shadow-control-elevated); + cursor: pointer; + border: 1px solid transparent; + + &:hover { + border: 1px solid var(--intergalactic-control-presets-border-hover); + } + + &[aria-checked='true'] { + border: 1px solid var(--intergalactic-control-presets-border-selected); + } + + &[aria-disabled='true'] { + pointer-events: none; + opacity: var(--intergalactic-disabled-opacity); + } +} + +SRadioItemHeader { + align-items: center; + gap: var(--intergalactic-spacing-content-gap-medium); +} diff --git a/semcore/radio-cards/tsconfig.json b/semcore/radio-cards/tsconfig.json new file mode 100644 index 0000000000..3ed2a1cc8b --- /dev/null +++ b/semcore/radio-cards/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json"], + "compilerOptions": { + "rootDir": "./src" + } +} diff --git a/semcore/radio-cards/vite.config.ts b/semcore/radio-cards/vite.config.ts new file mode 100644 index 0000000000..90f46b4cb2 --- /dev/null +++ b/semcore/radio-cards/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, mergeConfig } from 'vite'; + +import viteConfig from '../../commonVite.config'; + +export default mergeConfig( + viteConfig, + defineConfig({ + build: { + lib: { + entry: './src/index.ts', + }, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', /@babel\/runtime\/*/, /@semcore\/*/], + }, + }, + }), +); diff --git a/semcore/radio-cards/vitest.config.mts b/semcore/radio-cards/vitest.config.mts new file mode 100644 index 0000000000..36006d4f8e --- /dev/null +++ b/semcore/radio-cards/vitest.config.mts @@ -0,0 +1,17 @@ +import { resolve as resolvePath } from 'node:path'; + +import { defineConfig } from 'vitest/config'; + +import { vitestPlugins, vitestResolve } from '../../vitest.config.mts'; + +export default defineConfig({ + plugins: vitestPlugins, + resolve: vitestResolve, + test: { + include: [ + '__tests__/**/*.test.tsx', + ], + environment: 'jsdom', + setupFiles: [resolvePath(__dirname, '../../tools/testing-utils/setupTests')], + }, +}); diff --git a/stories/components/radio-cards/docs/examples/advanced.tsx b/stories/components/radio-cards/docs/examples/advanced.tsx new file mode 100644 index 0000000000..8da1a0e65b --- /dev/null +++ b/stories/components/radio-cards/docs/examples/advanced.tsx @@ -0,0 +1,49 @@ +import FileExport from '@semcore/icon/FileExport/m'; +import Info from '@semcore/icon/Info/m'; +import RadioCards from '@semcore/ui/radio-cards'; +import Spin from '@semcore/ui/spin'; +import Tag, { TagContainer } from '@semcore/ui/tag'; +import React from 'react'; + +const Demo = () => { + return ( + + + + + + + + Icons everywhere! + + + + + + Help! + + + + + + + + You can pass anything being in advanced mode! + + + + + + Even Tags! + + + + + + Help! + + + ); +}; + +export default Demo; diff --git a/stories/components/radio-cards/docs/examples/basic.tsx b/stories/components/radio-cards/docs/examples/basic.tsx new file mode 100644 index 0000000000..27d430b172 --- /dev/null +++ b/stories/components/radio-cards/docs/examples/basic.tsx @@ -0,0 +1,15 @@ +import ThumbUp from '@semcore/icon/ThumbUp/m'; +import RadioCards from '@semcore/ui/radio-cards'; +import React from 'react'; + +const Demo = () => { + return ( + + + + } description='Secondary text' /> + + ); +}; + +export default Demo; diff --git a/stories/components/radio-cards/docs/radio-cards.stories.tsx b/stories/components/radio-cards/docs/radio-cards.stories.tsx new file mode 100644 index 0000000000..96649f2cd7 --- /dev/null +++ b/stories/components/radio-cards/docs/radio-cards.stories.tsx @@ -0,0 +1,20 @@ +import RadioCards from '@semcore/ui/radio-cards'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import AdvancedExample from './examples/advanced'; +import BasicExample from './examples/basic'; + +const meta: Meta = { + title: 'Components/RadioCards/Documentation', + component: RadioCards, +}; + +export default meta; + +export const BasicRadioCardsExample: StoryObj = { + render: BasicExample, +}; + +export const AdvancedRadioCardsExample: StoryObj = { + render: AdvancedExample, +}; From 6bf93e12494cedde2645b946ed1b2b4304de36b2 Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Tue, 4 Aug 2026 12:19:00 +0200 Subject: [PATCH 2/7] [UIK-5640][radio-cards] add doc page/props description/changelog & package json changes --- semcore/radio-cards/CHANGELOG.md | 9 ++++ semcore/radio-cards/package.json | 4 +- semcore/radio-cards/src/RadioCards.type.ts | 10 ++++ .../src/styles/radio-cards.shadow.css | 20 ++++---- .../radio-cards/docs/examples/advanced.tsx | 4 +- website/docs/.vitepress/sidebarConfig.ts | 5 ++ .../radio-cards/radio-cards-a11y.md | 46 +++++++++++++++++++ .../components/radio-cards/radio-cards-api.md | 25 ++++++++++ .../radio-cards/radio-cards-changelog.md | 7 +++ .../radio-cards/radio-cards-code.md | 25 ++++++++++ .../components/radio-cards/radio-cards.md | 7 +++ .../style/design-tokens/design-tokens.json | 23 ++++++++-- 12 files changed, 166 insertions(+), 19 deletions(-) create mode 100644 website/docs/components/radio-cards/radio-cards-a11y.md create mode 100644 website/docs/components/radio-cards/radio-cards-api.md create mode 100644 website/docs/components/radio-cards/radio-cards-changelog.md create mode 100644 website/docs/components/radio-cards/radio-cards-code.md create mode 100644 website/docs/components/radio-cards/radio-cards.md diff --git a/semcore/radio-cards/CHANGELOG.md b/semcore/radio-cards/CHANGELOG.md index e69de29bb2..edc662bcc8 100644 --- a/semcore/radio-cards/CHANGELOG.md +++ b/semcore/radio-cards/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +CHANGELOG.md standards are inspired by [keepachangelog.com](https://keepachangelog.com/en/1.0.0/). + +## [18.0.0] - 2026-08-04 + +### Added + +- `RadioCards` component. diff --git a/semcore/radio-cards/package.json b/semcore/radio-cards/package.json index c5f86566ca..dc3b2d48c2 100644 --- a/semcore/radio-cards/package.json +++ b/semcore/radio-cards/package.json @@ -1,7 +1,7 @@ { "name": "@semcore/radio-cards", - "description": "Semrush Radio Cards Component", - "version": "17.2.1", + "description": "Semrush RadioCards Component", + "version": "18.0.0", "main": "lib/cjs/index.js", "module": "lib/es6/index.js", "typings": "lib/types/index.d.ts", diff --git a/semcore/radio-cards/src/RadioCards.type.ts b/semcore/radio-cards/src/RadioCards.type.ts index 737e52c788..3a357de347 100644 --- a/semcore/radio-cards/src/RadioCards.type.ts +++ b/semcore/radio-cards/src/RadioCards.type.ts @@ -5,11 +5,16 @@ import type { NSText } from '@semcore/typography'; declare namespace NSRadioCards { type Value = string; type Props = NSFlex.Props & Intergalactic.RequireAtLeastOne<{ + /** Accessible name for the radio group. */ 'aria-label'?: string; + /** References an element that labels the radio group. */ 'aria-labelledby'?: string; }> & { + /** Currently selected radio card value. */ value?: NSRadioCards.Value; + /** Called when the selected radio card changes. */ onChange?: (value: NSRadioCards.Value, event?: React.SyntheticEvent) => void; + /** Disables the entire radio group and all radio cards. */ disabled?: boolean; }; type DefaultProps = { @@ -21,10 +26,15 @@ declare namespace NSRadioCards { namespace Item { type Props = NSFlex.Props & { + /** Disables radio card. */ disabled?: boolean; + /** Optional icon displayed before the content. */ iconAddon?: React.ReactNode; + /** Unique value associated with the radio card. */ value: NSRadioCards.Value; + /** Additional text displayed in the header. */ textAddon?: string; + /** Description displayed below the header. */ description?: string; }; diff --git a/semcore/radio-cards/src/styles/radio-cards.shadow.css b/semcore/radio-cards/src/styles/radio-cards.shadow.css index d0aa7f8e0f..de1ada4a32 100644 --- a/semcore/radio-cards/src/styles/radio-cards.shadow.css +++ b/semcore/radio-cards/src/styles/radio-cards.shadow.css @@ -1,35 +1,35 @@ SRadioCards { - gap: var(--intergalactic-spacing-content-gap-large); + gap: var(--intergalactic-spacing-content-gap-large, 8px); } SRadioItem { flex-direction: column; flex: 1 0 0; - gap: var(--intergalactic-spacing-content-gap-small); - padding: var(--intergalactic-spacing-3x) var(--intergalactic-spacing-5x); + gap: var(--intergalactic-spacing-content-gap-small, 4px); + padding: var(--intergalactic-spacing-3x, 12px) var(--intergalactic-spacing-5x, 20px); align-items: flex-start; - border-radius: var(--intergalactic-pills-rounded); - background: var(--intergalactic-control-presets-bg); - box-shadow: var(--intergalactic-box-shadow-control-elevated); + border-radius: var(--intergalactic-pills-rounded, 6px); + background: var(--intergalactic-control-presets-bg, oklch(1 0 0)); + box-shadow: var(--intergalactic-box-shadow-control-elevated, 0px 0px 1px 0px oklch(0.176 0.033 175.7 / 0.07), 0px 1px 3px 0px oklch(0.176 0.033 175.7 / 0.07)); cursor: pointer; border: 1px solid transparent; &:hover { - border: 1px solid var(--intergalactic-control-presets-border-hover); + border: 1px solid var(--intergalactic-control-presets-border-hover, oklch(0.137 0.026 175.7 / 0.161)); } &[aria-checked='true'] { - border: 1px solid var(--intergalactic-control-presets-border-selected); + border: 1px solid var(--intergalactic-control-presets-border-selected, oklch(0.23 0.01 140)); } &[aria-disabled='true'] { pointer-events: none; - opacity: var(--intergalactic-disabled-opacity); + opacity: var(--intergalactic-disabled-opacity, 0.4); } } SRadioItemHeader { align-items: center; - gap: var(--intergalactic-spacing-content-gap-medium); + gap: var(--intergalactic-spacing-content-gap-medium, 6px); } diff --git a/stories/components/radio-cards/docs/examples/advanced.tsx b/stories/components/radio-cards/docs/examples/advanced.tsx index 8da1a0e65b..7af0d4f67c 100644 --- a/stories/components/radio-cards/docs/examples/advanced.tsx +++ b/stories/components/radio-cards/docs/examples/advanced.tsx @@ -28,13 +28,13 @@ const Demo = () => { - You can pass anything being in advanced mode! + Configuration...🀀 - Even Tags! + Tags! diff --git a/website/docs/.vitepress/sidebarConfig.ts b/website/docs/.vitepress/sidebarConfig.ts index a5f9562fe8..b367fece4f 100644 --- a/website/docs/.vitepress/sidebarConfig.ts +++ b/website/docs/.vitepress/sidebarConfig.ts @@ -354,6 +354,11 @@ export const sideBarConfig: SidebarConfig = [ activeMatch: '/components/radio/', text: 'Radio', }, + { + link: '/components/radio-cards/radio-cards', + activeMatch: '/components/radio-cards/', + text: 'RadioCards', + }, { link: '/components/scroll-area/scroll-area', activeMatch: '/components/scroll-area/', diff --git a/website/docs/components/radio-cards/radio-cards-a11y.md b/website/docs/components/radio-cards/radio-cards-a11y.md new file mode 100644 index 0000000000..2f5dcc6ae7 --- /dev/null +++ b/website/docs/components/radio-cards/radio-cards-a11y.md @@ -0,0 +1,46 @@ +--- +title: Radio Cards +fileSource: radio-cards +tabs: Design('radio-cards'), A11y('radio-cards-a11y'), API('radio-cards-api'), Examples('radio-cards-code'), Changelog('radio-cards-changelog') +--- + +## What component has + +Component `RadioCards` functions as a group of radio buttons. + +### Keyboard support + +Table: Keyboard support + +| Key | Function | +|------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Tab` | When focus moves to the `RadioCards`, it moves to the active `RadioCards.Item`. | +| `Left Arrow` , `Right Arrow` | Set `checked` value to the next/previous button in the group. If focus is on the last/first button, moves focus to the first/last button respectively. | + +See detailed information about the keyboard support for clickable elements in the [Keyboard control guide](/core-principles/a11y/a11y-keyboard#any-other-controls-filtertrigger-pills-tabline-etc). + +### Roles & attributes + +The following list describes roles and attributes that the component with already has. + +Table: Roles & attributes + +| Component / Element | Roles & attributes | Usage | +|---------------------|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `RadioCards` | `radiogroup` | Identifies the `div` element as a container for a group of radio buttons. Isn’t focusable because focus is managed using a roving tabindex strategy as described below. | +| `RadioCards.Item` | `radio` | Identifies the `div` element as an ARIA radio button. The accessible name is computed from the child text content of the `div` element. | +| | `tabIndex="0"` | Makes the radio button focusable and includes it in the page `Tab` sequence. Set on only one radio in the radio group. Moves with focus inside the radio group so the most recently focused radio button is included in the page `Tab` sequence. This approach to managing focus is described in the section on [roving tabindex](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd-roving-tabindex). | +| | `tabindex="-1"` | Makes the element focusable but not part of the page `Tab` sequence. Applied to all radio buttons contained in the radio group except for one that's included in the page `Tab` sequence. This approach to managing focus is described in the section on [Roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents). | +| | `aria-checked="true/false"` | Identifies whether radio buttons are checked or unchecked. | | + +## Considerations for developers + +Since `RadioCards` behaves as a radio group, do not forget to add the `aria-labelledby` or `aria-label` attribute to the root element. + +## Resources + +- [A11y style guide](https://a11y-style-guide.com/style-guide/section-forms.html#kssref-forms-radio-buttons) gives core recommendations for the accessible radio buttons. + +## Other recommendations + +Find more accessibility recommendations in the common [Accessibility guide](/core-principles/a11y/a11y). diff --git a/website/docs/components/radio-cards/radio-cards-api.md b/website/docs/components/radio-cards/radio-cards-api.md new file mode 100644 index 0000000000..ebe37f4880 --- /dev/null +++ b/website/docs/components/radio-cards/radio-cards-api.md @@ -0,0 +1,25 @@ +--- +title: Radio Cards +fileSource: radio-cards +tabs: Design('radio-cards'), A11y('radio-cards-a11y'), API('radio-cards-api'), Examples('radio-cards-code'), Changelog('radio-cards-changelog') +--- + +## RadioCards + +```jsx +import RadioCards from '@semcore/ui/radio-cards'; +; +``` + + + +## RadioCards.Item + +```jsx +import RadioCards from '@semcore/ui/radio-cards'; +; +``` + + + + diff --git a/website/docs/components/radio-cards/radio-cards-changelog.md b/website/docs/components/radio-cards/radio-cards-changelog.md new file mode 100644 index 0000000000..6df201a9cc --- /dev/null +++ b/website/docs/components/radio-cards/radio-cards-changelog.md @@ -0,0 +1,7 @@ +--- +title: Radio Cards +fileSource: radio-cards +tabs: Design('radio-cards'), A11y('radio-cards-a11y'), API('radio-cards-api'), Examples('radio-cards-code'), Changelog('radio-cards-changelog') +--- + +::: changelog radio-cards ::: diff --git a/website/docs/components/radio-cards/radio-cards-code.md b/website/docs/components/radio-cards/radio-cards-code.md new file mode 100644 index 0000000000..9b5edb9ebe --- /dev/null +++ b/website/docs/components/radio-cards/radio-cards-code.md @@ -0,0 +1,25 @@ +--- +title: Radio Cards +fileSource: radio-cards +tabs: Design('radio-cards'), A11y('radio-cards-a11y'), API('radio-cards-api'), Examples('radio-cards-code'), Changelog('radio-cards-changelog') +--- + +## Basic usage + +::: sandbox + + + +::: + +## Advanced usage + +::: sandbox + + + +::: \ No newline at end of file diff --git a/website/docs/components/radio-cards/radio-cards.md b/website/docs/components/radio-cards/radio-cards.md new file mode 100644 index 0000000000..5ad38b27c6 --- /dev/null +++ b/website/docs/components/radio-cards/radio-cards.md @@ -0,0 +1,7 @@ +--- +title: Radio Cards +fileSource: radio-cards +tabs: Design('radio-cards'), A11y('radio-cards-a11y'), API('radio-cards-api'), Examples('radio-cards-code'), Changelog('radio-cards-changelog') +--- + +TODO: Needs to be filled. \ No newline at end of file diff --git a/website/docs/style/design-tokens/design-tokens.json b/website/docs/style/design-tokens/design-tokens.json index bae9eb82fc..b3f87b72f4 100644 --- a/website/docs/style/design-tokens/design-tokens.json +++ b/website/docs/style/design-tokens/design-tokens.json @@ -2009,19 +2009,25 @@ "name": "--intergalactic-control-presets-bg", "value": "oklch(1 0 0)", "description": "Background of the Presets items.", - "components": [] + "components": [ + "radio-cards" + ] }, { "name": "--intergalactic-control-presets-border-selected", "value": "oklch(0.23 0.01 140)", "description": "Border color of the selected Preset item.", - "components": [] + "components": [ + "radio-cards" + ] }, { "name": "--intergalactic-control-presets-border-hover", "value": "oklch(0.137 0.026 175.7 / 0.161)", "description": "Border color of the hovered Preset item.", - "components": [] + "components": [ + "radio-cards" + ] }, { "name": "--intergalactic-progress-bar-bg", @@ -3022,6 +3028,7 @@ "link", "pills", "radio", + "radio-cards", "slider", "switch", "tab-line", @@ -3050,6 +3057,7 @@ "value": "0px 0px 1px 0px oklch(0.176 0.033 175.7 / 0.07), 0px 1px 3px 0px oklch(0.176 0.033 175.7 / 0.07)", "description": "Shadow of the Switch toggle.", "components": [ + "radio-cards", "switch" ] }, @@ -3219,7 +3227,8 @@ "components": [ "base-trigger", "dropdown-menu", - "inline-input" + "inline-input", + "radio-cards" ] }, { @@ -3234,6 +3243,7 @@ "dropdown-menu", "input", "radio", + "radio-cards", "select" ] }, @@ -3246,6 +3256,7 @@ "checkbox", "dropdown", "radio", + "radio-cards", "select" ] }, @@ -3329,7 +3340,9 @@ "name": "--intergalactic-pills-rounded", "value": "6px", "description": "Use for rounding Pills.", - "components": [] + "components": [ + "radio-cards" + ] }, { "name": "--intergalactic-popper-rounded", From 2ccfdcad87cc73720211e2c8c2ddf63f9cb2e957 Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Wed, 5 Aug 2026 10:52:57 +0200 Subject: [PATCH 3/7] [UIK-5640][radio-cards] add default color for addons --- semcore/radio-cards/src/RadioCards.tsx | 16 +++++++++++----- .../src/styles/radio-cards.shadow.css | 4 ++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/semcore/radio-cards/src/RadioCards.tsx b/semcore/radio-cards/src/RadioCards.tsx index 24c36d45ea..b139c8bc00 100644 --- a/semcore/radio-cards/src/RadioCards.tsx +++ b/semcore/radio-cards/src/RadioCards.tsx @@ -118,7 +118,7 @@ function Item( {value && {value}} {textAddon && ( - + {textAddon} @@ -138,16 +138,22 @@ function Header(props: Intergalactic.InternalTypings.InferComponentProps); } -function LeftAddon() { +function LeftAddon( + props: Intergalactic.InternalTypings.InferComponentProps, +) { const SItemHeaderAddon = Root; + const { styles } = props; - return ; + return sstyled(styles)(); } -function RightAddon() { +function RightAddon( + props: Intergalactic.InternalTypings.InferComponentProps, +) { const SItemHeaderAddon = Root; + const { styles } = props; - return ; + return sstyled(styles)(); } function Text() { diff --git a/semcore/radio-cards/src/styles/radio-cards.shadow.css b/semcore/radio-cards/src/styles/radio-cards.shadow.css index de1ada4a32..df4f4d99e7 100644 --- a/semcore/radio-cards/src/styles/radio-cards.shadow.css +++ b/semcore/radio-cards/src/styles/radio-cards.shadow.css @@ -33,3 +33,7 @@ SRadioItemHeader { align-items: center; gap: var(--intergalactic-spacing-content-gap-medium, 6px); } + +SItemHeaderAddon { + color: var(--intergalactic-text-secondary, oklch(0.088 0.026 147.7 / 0.583)); +} From 09b0876919561d52e69e9ed659e40505a1dee786 Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Wed, 5 Aug 2026 10:53:40 +0200 Subject: [PATCH 4/7] [UIK-5640][radio-cards] add default color for addons --- website/docs/style/design-tokens/design-tokens.json | 1 + 1 file changed, 1 insertion(+) diff --git a/website/docs/style/design-tokens/design-tokens.json b/website/docs/style/design-tokens/design-tokens.json index b3f87b72f4..72686e3608 100644 --- a/website/docs/style/design-tokens/design-tokens.json +++ b/website/docs/style/design-tokens/design-tokens.json @@ -2905,6 +2905,7 @@ "flags", "fullscreen-modal", "pills", + "radio-cards", "slider", "switch", "tab-panel", From 5988718eff8081edbbecb3115a3b4f8080c27409 Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Wed, 5 Aug 2026 16:33:23 +0200 Subject: [PATCH 5/7] [UIK-5640][radio-cards] update styles/package.json --- semcore/radio-cards/package.json | 3 +++ semcore/radio-cards/src/styles/radio-cards.shadow.css | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/semcore/radio-cards/package.json b/semcore/radio-cards/package.json index dc3b2d48c2..7b1583f3d0 100644 --- a/semcore/radio-cards/package.json +++ b/semcore/radio-cards/package.json @@ -19,6 +19,9 @@ "dependencies": { "@semcore/typography": "^17.2.1" }, + "peerDependencies": { + "@semcore/base-components": "^17.2.1" + }, "repository": { "type": "git", "url": "https://github.com/semrush/intergalactic.git", diff --git a/semcore/radio-cards/src/styles/radio-cards.shadow.css b/semcore/radio-cards/src/styles/radio-cards.shadow.css index df4f4d99e7..1e793cc7c4 100644 --- a/semcore/radio-cards/src/styles/radio-cards.shadow.css +++ b/semcore/radio-cards/src/styles/radio-cards.shadow.css @@ -6,7 +6,7 @@ SRadioItem { flex-direction: column; flex: 1 0 0; gap: var(--intergalactic-spacing-content-gap-small, 4px); - padding: var(--intergalactic-spacing-3x, 12px) var(--intergalactic-spacing-5x, 20px); + padding: var(--intergalactic-spacing-content-padding-medium, 12px) var(--intergalactic-spacing-content-padding-xlarge, 20px); align-items: flex-start; border-radius: var(--intergalactic-pills-rounded, 6px); From 6ecf7c47724e2366d3b922bb4c38d7f73eb8bad6 Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Wed, 5 Aug 2026 16:33:49 +0200 Subject: [PATCH 6/7] [UIK-5640][radio-cards] update styles/package.json --- website/docs/style/design-tokens/design-tokens.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/docs/style/design-tokens/design-tokens.json b/website/docs/style/design-tokens/design-tokens.json index 72686e3608..ea6a66449f 100644 --- a/website/docs/style/design-tokens/design-tokens.json +++ b/website/docs/style/design-tokens/design-tokens.json @@ -3196,6 +3196,7 @@ "dropdown", "input", "modal", + "radio-cards", "time-picker" ] }, @@ -3210,7 +3211,8 @@ "value": "20px", "description": "Extra large padding for content inside controls and surfaces.", "components": [ - "card" + "card", + "radio-cards" ] }, { From 247b944f4d4b6ce30c7e304b03a7b4a78b52182c Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Fri, 7 Aug 2026 14:35:38 +0200 Subject: [PATCH 7/7] [UIK-5640][radio-cards] update styles/markup for advanced mode --- semcore/radio-cards/src/RadioCards.tsx | 84 ++++--------------- semcore/radio-cards/src/RadioCards.type.ts | 31 +------ .../src/styles/radio-cards.shadow.css | 3 +- .../radio-cards/docs/examples/advanced.tsx | 50 +++++------ .../radio-cards/docs/examples/basic.tsx | 6 +- 5 files changed, 43 insertions(+), 131 deletions(-) diff --git a/semcore/radio-cards/src/RadioCards.tsx b/semcore/radio-cards/src/RadioCards.tsx index b139c8bc00..e026df1b99 100644 --- a/semcore/radio-cards/src/RadioCards.tsx +++ b/semcore/radio-cards/src/RadioCards.tsx @@ -2,7 +2,6 @@ import { Flex, NeighborLocation } from '@semcore/base-components'; import type { Intergalactic } from '@semcore/core'; import { Root, Component, createComponent, sstyled } from '@semcore/core'; import a11yEnhance from '@semcore/core/lib/utils/enhances/a11yEnhance'; -import { isAdvanceMode } from '@semcore/core/lib/utils/findComponent'; import { Text as SemcoreText } from '@semcore/typography'; import React from 'react'; @@ -91,13 +90,12 @@ function Item( >, ) { const SRadioItem = Root; - const { Children, styles, iconAddon, value, textAddon, description, disabled, checked } = props; + const SRadioItemHeader = Flex; + const SRadioItemHeaderLeftAddon = Flex; + const SRadioItemHeaderRightAddon = SemcoreText; + const { Children, styles, iconAddon, text, textAddon, description, disabled, checked, children } = props; - const advancedMode = isAdvanceMode( - Children, - [RadioCards.Item.Header.displayName, RadioCards.Item.Description.displayName], - true, - ); + const isAdvancedMode = children !== undefined; return sstyled(styles)( - {advancedMode + {isAdvancedMode ? ( ) : ( <> - - {iconAddon && {iconAddon}} - {value && {value}} + + {iconAddon && {iconAddon}} + {text && {text}} {textAddon && ( - - - {textAddon} - - + + {textAddon} + )} - - {description && {description}} + + {description && {description}} )} , ); } -function Header(props: Intergalactic.InternalTypings.InferComponentProps) { - const SRadioItemHeader = Root; - const { styles } = props; - - return sstyled(styles)(); -} - -function LeftAddon( - props: Intergalactic.InternalTypings.InferComponentProps, -) { - const SItemHeaderAddon = Root; - const { styles } = props; - - return sstyled(styles)(); -} - -function RightAddon( - props: Intergalactic.InternalTypings.InferComponentProps, -) { - const SItemHeaderAddon = Root; - const { styles } = props; - - return sstyled(styles)(); -} - -function Text() { - const SItemHeaderText = Root; - - return ; -} - -function Description() { - const SItemDescription = Root; - - return ; -} - const RadioCards = createComponent(RadioCardsRoot, { - Item: [ - Item, - { - Header: [ - Header, - { - LeftAddon, - Text, - RightAddon, - }, - ], - Description, - }, - ], + Item, }); export default RadioCards; diff --git a/semcore/radio-cards/src/RadioCards.type.ts b/semcore/radio-cards/src/RadioCards.type.ts index 3a357de347..14e7ce093d 100644 --- a/semcore/radio-cards/src/RadioCards.type.ts +++ b/semcore/radio-cards/src/RadioCards.type.ts @@ -30,6 +30,8 @@ declare namespace NSRadioCards { disabled?: boolean; /** Optional icon displayed before the content. */ iconAddon?: React.ReactNode; + /** Optional text displayed in the header. */ + text?: string; /** Unique value associated with the radio card. */ value: NSRadioCards.Value; /** Additional text displayed in the header. */ @@ -38,34 +40,7 @@ declare namespace NSRadioCards { description?: string; }; - namespace Header { - namespace LeftAddon { - type Component = NSFlex.Component; - } - - namespace Text { - type Component = NSText.Component; - } - - namespace RightAddon { - type Component = NSFlex.Component; - } - - type Component = NSFlex.Component & { - LeftAddon: LeftAddon.Component; - Text: Text.Component; - RightAddon: RightAddon.Component; - }; - } - - namespace Description { - type Component = NSText.Component; - } - - type Component = Intergalactic.Component<'div', Props> & { - Header: Header.Component; - Description: Description.Component; - }; + type Component = Intergalactic.Component<'button', Props>; } type Component = Intergalactic.Component<'div', Props> & { diff --git a/semcore/radio-cards/src/styles/radio-cards.shadow.css b/semcore/radio-cards/src/styles/radio-cards.shadow.css index 1e793cc7c4..c8e0bbf478 100644 --- a/semcore/radio-cards/src/styles/radio-cards.shadow.css +++ b/semcore/radio-cards/src/styles/radio-cards.shadow.css @@ -34,6 +34,7 @@ SRadioItemHeader { gap: var(--intergalactic-spacing-content-gap-medium, 6px); } -SItemHeaderAddon { +SRadioItemHeaderLeftAddon, +SRadioItemHeaderRightAddon { color: var(--intergalactic-text-secondary, oklch(0.088 0.026 147.7 / 0.583)); } diff --git a/stories/components/radio-cards/docs/examples/advanced.tsx b/stories/components/radio-cards/docs/examples/advanced.tsx index 7af0d4f67c..02c4799591 100644 --- a/stories/components/radio-cards/docs/examples/advanced.tsx +++ b/stories/components/radio-cards/docs/examples/advanced.tsx @@ -1,46 +1,36 @@ import FileExport from '@semcore/icon/FileExport/m'; import Info from '@semcore/icon/Info/m'; +import { Flex } from '@semcore/ui/base-components'; import RadioCards from '@semcore/ui/radio-cards'; import Spin from '@semcore/ui/spin'; import Tag, { TagContainer } from '@semcore/ui/tag'; +import { Text } from '@semcore/ui/typography'; import React from 'react'; const Demo = () => { return ( - - - - - - Icons everywhere! - - - - - - Help! + + + Icons everywhere! + + + Help! - - - - - - Configuration...🀀 - - - - - - Tags! - - - - - - Help! + + + Configuration...🀀 + + + + Tags! + + + + + Help! ); diff --git a/stories/components/radio-cards/docs/examples/basic.tsx b/stories/components/radio-cards/docs/examples/basic.tsx index 27d430b172..84e6ca6202 100644 --- a/stories/components/radio-cards/docs/examples/basic.tsx +++ b/stories/components/radio-cards/docs/examples/basic.tsx @@ -5,9 +5,9 @@ import React from 'react'; const Demo = () => { return ( - - - } description='Secondary text' /> + + + } description='Secondary text' /> ); };