Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/item-selected-disabled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': patch
---

`Item` themes: a disabled item now keeps showing whether it is selected. The `disabled` entry in every `outline`, `outline-2` and `clear` variant used to override `selected` outright, so a disabled segmented control — `RadioGroup type="button"` most visibly — rendered every option identically with no sign of which one was active. Each of those variants gains a `selected & disabled` state that paints the brand-tinted `accent-disabled-surface` chip and its paired label instead of the neutral one, across all six themes (`special` and `current` stay in their own white-alpha / `currentcolor` registers). `type="item"` rows are unchanged — they already keep brand identity in their disabled label and pair selection with a checkmark.
5 changes: 5 additions & 0 deletions .changeset/radio-button-item-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cube-dev/ui-kit': minor
---

`Radio` / `Radio.Button`: a button- or tabs-type radio now accepts the full content API of the `Item` it renders, matching `ItemButton`. Newly forwarded: `descriptionPlacement`, `descriptionProps`, `keyboardShortcutProps`, `isLoading`, `loadingSlot`, `highlight`, `highlightCaseSensitive`, `highlightStyles`, `level` and `labelRef`. `description` now has a grid area to land in, so it renders in both `inline` and `block` placement, and container style props (`padding`, `gap`, `fill`, `preset`, …) apply to button-type radios instead of being dropped. A loading button radio is also disabled, so it no longer takes clicks or arrow-key selection — pass an explicit `isDisabled={false}` to opt out.
59 changes: 59 additions & 0 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,62 @@
<!--
Pin the wall clock for every story and docs page.

Anything that asks the browser what day it is renders differently tomorrow.
`Calendar` is the loudest case: with no value, React Aria's `useCalendarState`
focuses the current month and `useCalendarCell` highlights today, both read
from `today(timeZone)` -> `Date.now()`. So every story showing a calendar
(Calendar, DatePicker, DateRangePicker, PeriodPicker, and the docs pages that
embed them) produced a fresh Chromatic diff on each new day, and a much bigger
one on each new month.

The clock still ticks — it is offset to the pinned instant rather than stopped
— so anything measuring elapsed time keeps working (toast auto-dismiss in
`src/_internal/hooks/use-timer`, notification relative timestamps, React Aria's
own timers). Only the absolute date is fixed, which is all Chromatic needs.

This lives in `preview-head.html`, not `preview.jsx`, so it is installed
before the preview bundle evaluates and cannot lose a race with a module that
reads the clock at import time.

Storybook only — the UI Kit itself has no date override, and consumers get the
real clock. To move the pinned date, edit PINNED_DATE below; expect a one-time
Chromatic diff on every date-related story when you do.
-->
<script>
(function pinClock() {
// Noon UTC keeps the calendar date identical across almost every timezone,
// so a local Storybook and Chromatic agree on which day is highlighted.
var PINNED_DATE = '2025-06-15T12:00:00.000Z';

var NativeDate = window.Date;
var pinnedStart = NativeDate.parse(PINNED_DATE);
var realStart = NativeDate.now();

function pinnedNow() {
return pinnedStart + (NativeDate.now() - realStart);
}

// A Proxy rather than a subclass: `Date` is also callable without `new`
// (returning a string), and everything else — `Date.parse`, `Date.UTC`,
// `instanceof`, the prototype — passes straight through untouched.
window.Date = new Proxy(NativeDate, {
apply: function () {
return new NativeDate(pinnedNow()).toString();
},
construct: function (target, args, newTarget) {
return Reflect.construct(
target,
args.length === 0 ? [pinnedNow()] : args,
newTarget,
);
},
get: function (target, prop, receiver) {
return prop === 'now' ? pinnedNow : Reflect.get(target, prop, receiver);
},
});
})();
</script>

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">
Expand Down
147 changes: 111 additions & 36 deletions src/components/fields/RadioGroup/Radio.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useFocusableRef } from '@react-spectrum/utils';
import {
BaseProps,
CONTAINER_STYLES,
ContainerStyleProps,
filterBaseProps,
OUTER_STYLES,
OuterStyleProps,
tasty,
} from '@tenphi/tasty';
import { forwardRef, useMemo, useRef } from 'react';
Expand Down Expand Up @@ -38,8 +39,20 @@ const RadioButtonElement = tasty(Item, {
preset: 't3m',
lineHeight: '1em',
flexGrow: 1,
gridTemplate:
'"icon prefix label suffix rightIcon actions" auto / max-content max-content max-content max-content max-content max-content',
// Same areas as `Item`, but every column is `max-content` — a radio button
// is sized by its content, not stretched like a list item. The
// `description` rows have to be repeated here because a plain string would
// replace `Item`'s whole state map and leave the description without a
// grid area to land in.
gridTemplate: {
'': '"icon prefix label suffix rightIcon actions" auto / max-content max-content max-content max-content max-content max-content',
'description=inline':
'"icon prefix description suffix rightIcon actions" auto / max-content max-content max-content max-content max-content max-content',
'description=inline & has-label':
'"icon prefix label suffix rightIcon actions" auto "icon prefix description suffix rightIcon actions" auto / max-content max-content max-content max-content max-content max-content',
'description=block':
'"icon prefix label suffix rightIcon actions" auto "description description description description description description" auto / max-content max-content max-content max-content max-content max-content',
},
placeContent: 'center',
shadow: {
'': false,
Expand Down Expand Up @@ -161,32 +174,68 @@ const RadioLabelElement = tasty({
styles: INLINE_LABEL_STYLES,
});

/**
* `Item` props a button-type radio hands straight to the `Item` it renders, so
* `Radio.Button` accepts the same content and presentation API as an
* `ItemButton`. Kept as a runtime list so a new `Item` prop only has to be
* added in one place.
*
* Deliberately absent:
* - `size`, `type`, `theme`, `isSelected`, `isDisabled`, `mods`, `styles` —
* owned by the radio (they come from the prop/context/validation resolution
* below).
* - `shape` — the radius is owned by the button-group layout below, which keeps
* only the outer-side radius on the first/last radio so the group reads as one
* continuous control. Forwarding `shape` would have no effect.
* - `htmlType`, `as`, `insideWrapper`, `showActions` — internal to `Item` /
* `ItemButton`.
* - `actions`, `autoHideActions`, `preserveActionsSpace`,
* `disableActionsFocus` — a button radio renders as a `<label>`, and a click
* anywhere inside a label activates its control, so nested action buttons
* would select the radio (and nested interactive content is invalid inside a
* label to begin with).
*/
const ITEM_PROPS = [
'icon',
'rightIcon',
'prefix',
'suffix',
'description',
'descriptionPlacement',
'descriptionProps',
'tooltip',
'hotkeys',
'keyboardShortcutProps',
'level',
'isLoading',
'loadingSlot',
'highlight',
'highlightCaseSensitive',
'highlightStyles',
'labelRef',
] as const;

type RadioItemProps = Pick<CubeItemProps, (typeof ITEM_PROPS)[number]>;

export interface CubeRadioProps
extends BaseProps,
AriaRadioProps,
Omit<FieldBaseProps, 'tooltip'>,
OuterStyleProps {
/**
* Container style props apply to button-type radios (they style the `Item`
* the radio renders). A classic radio only reads the outer subset —
* everything else belongs to its inner circle and label.
*/
ContainerStyleProps,
/** All of these apply to button/tabs-type radios only. */
RadioItemProps {
'aria-label'?: string;
/* The visual type of the radio button */
type?: 'button' | 'radio';
buttonType?: CubeItemProps['type'];
value?: string;
/* Size of the button (for button type only) */
size?: Omit<CubeItemProps['size'], 'inline'>;
/* Icon to display (for button type only) */
icon?: CubeItemProps['icon'];
/* Icon to display on the right (for button type only) */
rightIcon?: CubeItemProps['rightIcon'];
/* Prefix element (for button type only) */
prefix?: CubeItemProps['prefix'];
/* Suffix element (for button type only) */
suffix?: CubeItemProps['suffix'];
/* Description text (for button type only) */
description?: CubeItemProps['description'];
/* Tooltip configuration (for button type only) */
tooltip?: CubeItemProps['tooltip'];
/* Keyboard shortcut (for button type only) */
hotkeys?: CubeItemProps['hotkeys'];
}

function Radio(props: CubeRadioProps, ref) {
Expand All @@ -205,21 +254,24 @@ function Radio(props: CubeRadioProps, ref) {
type,
buttonType,
size,
icon,
rightIcon,
prefix,
suffix,
description,
tooltip,
hotkeys,
'aria-label': ariaLabel,
form,
...otherProps
} = props;

label = label || children;

let styles = extractStyles(otherProps, OUTER_STYLES);
// Only the props the caller actually passed, so an unset one keeps `Item`'s
// own default instead of being overridden with `undefined`.
const itemProps: Record<string, unknown> = {};

for (const itemPropName of ITEM_PROPS) {
if (itemPropName in props) {
itemProps[itemPropName] = (props as Record<string, unknown>)[
itemPropName
];
}
}

labelStyles = {
...INLINE_LABEL_STYLES,
Expand All @@ -243,6 +295,15 @@ function Radio(props: CubeRadioProps, ref) {
let effectiveType = type ?? contextType ?? 'radio';
let isButton = effectiveType === 'button' || effectiveType === 'tabs';

// A button radio *is* an `Item`, so it takes the same container style props an
// `ItemButton` does. A classic radio only exposes the outer subset: the rest
// would land on the wrapper that holds the circle and the label, where
// `padding` / `fill` / `preset` mean something entirely different.
let styles = extractStyles(
otherProps,
isButton ? CONTAINER_STYLES : OUTER_STYLES,
);

// Determine effective size with priority: prop > context > default
let effectiveSize: CubeItemProps['size'] = (size ??
contextSize ??
Expand All @@ -261,7 +322,10 @@ function Radio(props: CubeRadioProps, ref) {
// When buttonType is 'primary', non-selected radios use 'outline' with a
// visual-only `selected` mod (mods.selected=true) to render the brand-tinted
// outline+selected look — this is the only place mods.selected intentionally
// decouples from the aria/isSelected state.
// decouples from the aria/isSelected state. Suppressed while disabled: the
// real selected radio is then painted with the brand-tinted disabled chip
// (`accent-disabled-surface`), and faking `selected` on its siblings would
// give them the outline variant of that same chip and erase the distinction.
let forceSelectedMod = false;
if (effectiveType === 'tabs') {
effectiveButtonType = 'clear';
Expand All @@ -280,7 +344,21 @@ function Radio(props: CubeRadioProps, ref) {
}

// Use context isDisabled if prop isDisabled is not explicitly set
let effectiveIsDisabled = isDisabled ?? contextIsDisabled ?? false;
let baseIsDisabled = isDisabled ?? contextIsDisabled ?? false;

// Loading has to disable the radio here, not inside `Item`. `useRadio` and the
// `HiddenInput` covering the button own the actual selection, so leaving it to
// `Item` would render a spinner on an option that still takes clicks and arrow
// keys. And because this component always passes a resolved `isDisabled` down,
// an unresolved `isLoading` would additionally read to `Item` as an explicit
// `isDisabled={false}` and cancel its own loading-disables-the-item rule.
//
// Same precedence as `Item` / `ItemButton`: an explicit `isDisabled={false}`
// still wins over loading. Button-type only — a classic radio has no spinner,
// so disabling it on `isLoading` would leave no visible reason why.
let isLoadingButton = isButton && props.isLoading === true;
let effectiveIsDisabled =
baseIsDisabled === true || (isLoadingButton && isDisabled !== false);

let { isFocused, focusProps } = useFocus(
{ isDisabled: effectiveIsDisabled },
Expand Down Expand Up @@ -339,16 +417,13 @@ function Radio(props: CubeRadioProps, ref) {
{ includeValid: true },
)}
size={effectiveSize}
icon={icon}
rightIcon={rightIcon}
prefix={prefix}
suffix={suffix}
description={description}
tooltip={tooltip}
hotkeys={hotkeys}
Comment thread
cursor[bot] marked this conversation as resolved.
{...itemProps}
isSelected={isRadioSelected}
isDisabled={isRadioDisabled}
mods={{ ...mods, ...(forceSelectedMod ? { selected: true } : {}) }}
mods={{
...mods,
...(forceSelectedMod && !isRadioDisabled ? { selected: true } : {}),
}}
styles={styles}
{...mergeProps(hoverProps, focusProps)}
>
Expand Down
38 changes: 38 additions & 0 deletions src/components/fields/RadioGroup/RadioGroup.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ A radio group allows users to select exactly one option from a set of mutually e
- **`onBlur`** `function` — Callback fired when the radio group loses focus
- **`onFocus`** `function` — Callback fired when the radio group receives focus

### Radio Properties

Each option is a `Radio` (also exported as `Radio.Button`).

- **`value`** `string` — The value this option contributes to the group
- **`children`** — The option label
- **`isDisabled`** `boolean` — Whether this option is disabled. Falls back to the group's `isDisabled`
- **`type`** `'radio' | 'button'` — Overrides the group's visual type for this option
- **`size`**, **`buttonType`** — Override the group's `size` / `buttonType` for this option

A `button`- or `tabs`-type radio renders an [Item](/docs/content-item--docs), so it takes the same content props an [ItemButton](/docs/actions-itembutton--docs) does. These are ignored by a classic radio:

- **`icon`**, **`rightIcon`** — Icons before / after the label
- **`prefix`**, **`suffix`** — Content before / after the label
- **`description`**, **`descriptionPlacement`**, **`descriptionProps`** — Secondary line, placed `inline` (default) or `block`
- **`tooltip`** — Tooltip content, or `true` to show one only when the label is truncated
- **`hotkeys`**, **`keyboardShortcutProps`** — Keyboard shortcut that selects the option, rendered as a hint
- **`isLoading`**, **`loadingSlot`** — Replaces a slot with a spinner and disables the option
- **`highlight`**, **`highlightCaseSensitive`**, **`highlightStyles`** — Highlights a substring of the label
- **`level`** — Heading level for the label when `buttonType` is `card` or `header`
- **`labelRef`** — Ref to the label element

Container [style properties](#style-properties) also apply to button-type radios (they style the `Item`). A classic radio only reads the outer subset — `margin`, `width`, `height`, `flex*`, `grid*`, `place*`, `position`, `inset`, `zIndex`, `border`, `radius`, `shadow`, `outline`.

`shape` and `actions` are not forwarded: the corner radius is owned by the group layout, which keeps only the outer-side radius on the first and last option so the group reads as one continuous control, and a button radio renders as a `<label>`, where a click on a nested action button would select the option.

### Base Properties

Supports [Base properties](/docs/getting-started-base-properties--docs)
Expand Down Expand Up @@ -166,6 +192,18 @@ When using `type="button"`, you can customize the button appearance:
</Radio.Tabs>
```

### Rich Button Content

<Story of={RadioGroupStories.ButtonGroupRichContent} />

```jsx
<Radio.ButtonGroup label="Region" defaultValue="eu">
<Radio value="eu" icon={<GlobeIcon />} description="Frankfurt">EU</Radio>
<Radio value="us" icon={<GlobeIcon />} description="Oregon" hotkeys="alt+2">US</Radio>
<Radio value="ap" icon={<GlobeIcon />} isLoading>AP</Radio>
</Radio.ButtonGroup>
```

### Custom Button Type

```jsx
Expand Down
Loading
Loading