Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions semcore/radio-cards/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
__tests__
src
tsconfig.json
tsconfig.tsbuildinfo
9 changes: 9 additions & 0 deletions semcore/radio-cards/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions semcore/radio-cards/README.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 92 additions & 0 deletions semcore/radio-cards/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<RadioCards aria-label='Radio cards' value='' onChange={onChange}>
<RadioCards.Item value='1'>1</RadioCards.Item>
<RadioCards.Item value='2'>2</RadioCards.Item>
<RadioCards.Item value='4'>4</RadioCards.Item>
</RadioCards>,
);

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(
<RadioCards aria-label='Radio cards' value='' onChange={spy}>
<RadioCards.Item value='1'>1</RadioCards.Item>
<RadioCards.Item value='2'>2</RadioCards.Item>
<RadioCards.Item value='3'>3</RadioCards.Item>
<RadioCards.Item value='4' disabled>4</RadioCards.Item>
</RadioCards>,
);

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(
<RadioCards
aria-label='Radio cards'
value=''
disabled
onChange={spy}
>
<RadioCards.Item value='1'>1</RadioCards.Item>
<RadioCards.Item value='2'>2</RadioCards.Item>
</RadioCards>,
);

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(
<RadioCards aria-label='Radio cards' value='1' onChange={spy}>
<RadioCards.Item value='1'>1</RadioCards.Item>
<RadioCards.Item value='2'>2</RadioCards.Item>
<RadioCards.Item value='3'>3</RadioCards.Item>
</RadioCards>,
);

const firstRadio = getByRole('radio', { name: '1' });

firstRadio.focus();
expect(firstRadio).toHaveFocus();

await userEvent.keyboard('{ArrowRight}');

expect(spy).toHaveBeenCalledWith(
'2',
expect.any(Object),
);
});
});
35 changes: 35 additions & 0 deletions semcore/radio-cards/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@semcore/radio-cards",
"description": "Semrush RadioCards Component",
"version": "18.0.0",
"main": "lib/cjs/index.js",
"module": "lib/es6/index.js",
"typings": "lib/types/index.d.ts",
"sideEffects": false,
"author": "UI-kit team <ui-kit-team@semrush.com>",
"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"
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should add peerDeps. Core and base-components, I think

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

"peerDependencies": {
"@semcore/base-components": "^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:*"
}
}
134 changes: 134 additions & 0 deletions semcore/radio-cards/src/RadioCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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 { 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<NSRadioCards.Component>,
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)(
<SRadioCards render={Flex} role='radiogroup' use:tabIndex={value !== '' ? -1 : 0} aria-disabled={disabled}>
<NeighborLocation>
<Children />
</NeighborLocation>
</SRadioCards>,
);
}
}

function Item(
props: Intergalactic.InternalTypings.InferChildComponentProps<
NSRadioCards.Item.Component,
typeof RadioCardsRoot,
'Item'
>,
) {
const SRadioItem = Root;
const SRadioItemHeader = Flex;
const SRadioItemHeaderLeftAddon = Flex;
const SRadioItemHeaderRightAddon = SemcoreText;
const { Children, styles, iconAddon, text, textAddon, description, disabled, checked, children } = props;

const isAdvancedMode = children !== undefined;

return sstyled(styles)(
<SRadioItem
render={Flex}
tag='button'
role='radio'
aria-disabled={disabled}
aria-checked={checked}
>
{isAdvancedMode
? (
<Children />
)
: (
<>
<SRadioItemHeader>
{iconAddon && <SRadioItemHeaderLeftAddon>{iconAddon}</SRadioItemHeaderLeftAddon>}
{text && <SemcoreText size={300} use='primary'>{text}</SemcoreText>}
{textAddon && (
<SRadioItemHeaderRightAddon size={300}>
{textAddon}
</SRadioItemHeaderRightAddon>
)}
</SRadioItemHeader>
{description && <SemcoreText size={200} use='secondary'>{description}</SemcoreText>}
</>
)}
</SRadioItem>,
);
}

const RadioCards = createComponent<NSRadioCards.Component, typeof RadioCardsRoot>(RadioCardsRoot, {
Item,
});

export default RadioCards;
51 changes: 51 additions & 0 deletions semcore/radio-cards/src/RadioCards.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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<{
/** 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<HTMLButtonElement>) => void;
/** Disables the entire radio group and all radio cards. */
disabled?: boolean;
};
type DefaultProps = {
defaultValue: '';
};
type Handlers = {
value: NSRadioCards.Value;
};

namespace Item {
type Props = NSFlex.Props & {
/** Disables radio card. */
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. */
textAddon?: string;
/** Description displayed below the header. */
description?: string;
};

type Component = Intergalactic.Component<'button', Props>;
}

type Component = Intergalactic.Component<'div', Props> & {
Item: Item.Component;
};
}

export type { NSRadioCards };
2 changes: 2 additions & 0 deletions semcore/radio-cards/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './RadioCards';
export * from './RadioCards.type';
Loading