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
13 changes: 11 additions & 2 deletions docs/widgets/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,17 @@
},
{
"name": "radioGroup",
"requiredProps": ["id", "value", "options", "onChange"],
"commonProps": ["direction", "disabled"],
"requiredProps": ["id", "value", "options"],
"commonProps": [
"onChange",
"direction",
"disabled",
"focusable",
"accessibleLabel",
"focusConfig",
"dsTone",
"dsSize"
],
"example": "ui.radioGroup({ id: 'plan', value, options, onChange })"
},
{
Expand Down
18 changes: 12 additions & 6 deletions docs/widgets/radio-group.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,24 @@ ui.radioGroup({
|---|---|---|---|
| `id` | `string` | **required** | Unique identifier for focus and event routing |
| `value` | `string` | **required** | Currently selected value |
| `options` | `{ value: string; label: string }[]` | **required** | Available options |
| `onChange` | `(value: string) => void` | - | Called when selection changes |
| `options` | `{ value: string; label: string; disabled?: boolean }[]` | **required** | Available options; disabled entries stay visible but are skipped by keyboard selection |
| `onChange` | `(value: string) => void` | - | Called when arrow-key navigation changes the selected value |
| `direction` | `"horizontal" \| "vertical"` | `"vertical"` | Layout direction |
| `disabled` | `boolean` | `false` | Disable focus and interaction |
| `focusable` | `boolean` | `true` | Opt out of Tab order while keeping id-based routing available |
| `accessibleLabel` | `string` | - | Optional semantic label for announcements and debugging |
| `focusConfig` | `FocusConfig` | theme default | Optional focus-indicator configuration |
| `dsTone` | `"default" \| "primary" \| "danger" \| "success" \| "warning"` | `"default"` | Design-system tone for selected/focus rendering |
| `dsSize` | `"sm" \| "md" \| "lg"` | `"md"` | Design-system size preset |
| `key` | `string` | - | Reconciliation key |

## Behavior

- Focusable when enabled.
- **Mouse click** focuses the radio group.
- Navigate choices with **ArrowUp/ArrowDown** (or left/right in horizontal layouts).
- Confirm with **Enter**.
- Disabled options remain rendered with disabled styling and are skipped by arrow-key navigation.
- **Mouse click** focuses the radio group but does not select an option.
- Navigate choices with **ArrowUp/ArrowDown** in vertical groups or **ArrowLeft/ArrowRight** in horizontal groups.
- **Enter** does not change the current selection.
- **Tab / Shift+Tab** moves focus in/out.

## Examples
Expand Down Expand Up @@ -73,6 +79,6 @@ ui.radioGroup({

## Related

- [Select](select.md) - Dropdown single-choice input
- [Select](select.md) - Inline single-choice cycler
- [Checkbox](checkbox.md) - Boolean toggle
- [Input & Focus](../guide/input-and-focus.md) - Focus navigation rules
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,61 @@ describe("WidgetRenderer integration battery", () => {
assert.equal(renderer.getFocusedId(), "z3");
});

test("radioGroup arrows skip disabled options and Enter does not change selection", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
backend,
requestRender: () => {},
});

const changes: string[] = [];
let selected = "free";
const view = () =>
ui.column({}, [
ui.radioGroup({
id: "plan",
value: selected,
options: [
{ value: "free", label: "Free" },
{ value: "pro", label: "Pro", disabled: true },
{ value: "enterprise", label: "Enterprise" },
],
onChange: (value) => {
changes.push(value);
selected = value;
},
}),
]);

const res = renderer.submitFrame(
view,
undefined,
{ cols: 40, rows: 10 },
defaultTheme,
noRenderHooks(),
);
assert.ok(res.ok);
assert.equal(renderer.getFocusedId(), null);

renderer.routeEngineEvent(keyEvent(3 /* TAB */));
assert.equal(renderer.getFocusedId(), "plan");

renderer.routeEngineEvent(keyEvent(21 /* DOWN */));
const rerender = renderer.submitFrame(
view,
undefined,
{ cols: 40, rows: 10 },
defaultTheme,
noRenderHooks(),
);
assert.ok(rerender.ok);
assert.equal(selected, "enterprise");

renderer.routeEngineEvent(keyEvent(2 /* ENTER */));
assert.deepEqual(changes, ["enterprise"]);
assert.equal(selected, "enterprise");
});

test("focusZone preserves single-line input arrow editing while active", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { assert, describe, test } from "@rezi-ui/testkit";
import {
darkTheme,
dimmedTheme,
draculaTheme,
highContrastTheme,
lightTheme,
nordTheme,
} from "../../theme/presets.js";
import { compileTheme } from "../../theme/theme.js";
import { ui } from "../../widgets/ui.js";
import { type DrawOp, renderOps } from "./recipeRendering.test-utils.js";

const DS_THEMES = [
["dark", compileTheme(darkTheme)],
["light", compileTheme(lightTheme)],
["dimmed", compileTheme(dimmedTheme)],
["high-contrast", compileTheme(highContrastTheme)],
["nord", compileTheme(nordTheme)],
["dracula", compileTheme(draculaTheme)],
] as const;

function findTextOp(ops: readonly DrawOp[], text: string): DrawOp | undefined {
return ops.find((op) => op.kind === "drawText" && op.text.includes(text));
}

describe("radioGroup recipe rendering", () => {
test("renders disabled options with disabled recipe colors", () => {
for (const [name, theme] of DS_THEMES) {
const ops = renderOps(
ui.radioGroup({
id: "plans",
value: "pro",
options: [
{ value: "free", label: "Free" },
{ value: "pro", label: "Pro", disabled: true },
{ value: "enterprise", label: "Enterprise" },
],
}),
{
viewport: { cols: 40, rows: 4 },
theme,
},
);

const label = findTextOp(ops, "Pro");
assert.ok(label && label.kind === "drawText", `${name} theme should render disabled label`);
if (!label || label.kind !== "drawText") continue;

const indicator = ops.find(
(op): op is Extract<DrawOp, { kind: "drawText" }> =>
op.kind === "drawText" && op.text === "(o)" && op.y === label.y,
);
assert.ok(indicator, `${name} theme should render disabled selected indicator`);
if (!indicator) continue;

assert.deepEqual(indicator.style?.fg, theme.colors["disabled.fg"]);
assert.deepEqual(label.style?.fg, theme.colors["disabled.fg"]);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
selectRecipe,
sliderRecipe,
} from "../../../ui/recipes.js";
import { DEFAULT_PLACEHOLDER, getSelectDisplayText } from "../../../widgets/select.js";
import {
FIELD_ERROR_STYLE,
FIELD_HINT_STYLE,
Expand All @@ -22,6 +21,7 @@ import {
getFieldFooterText,
shouldShowError,
} from "../../../widgets/field.js";
import { DEFAULT_PLACEHOLDER, getSelectDisplayText } from "../../../widgets/select.js";
import {
DEFAULT_SLIDER_TRACK_WIDTH,
formatSliderValue,
Expand Down Expand Up @@ -1151,9 +1151,10 @@ export function renderFormWidgets(
if (typeof optionValue !== "string" || typeof optionLabel !== "string") continue;

const selected = optionValue === value;
const optionDisabled = disabled || (opt as { disabled?: unknown }).disabled === true;
const mark = selected ? "(o)" : "( )";
if (colorTokens !== null) {
const state = disabled
const state = optionDisabled
? ("disabled" as const)
: focusVisible && selected
? ("focus" as const)
Expand Down Expand Up @@ -1191,7 +1192,7 @@ export function renderFormWidgets(
const focusStyle = focusVisible ? { underline: true, bold: true } : undefined;
const baseStyle = mergeTextStyle(
parentStyle,
disabled ? { fg: theme.colors.muted, ...focusStyle } : focusStyle,
optionDisabled ? { fg: theme.colors.muted, ...focusStyle } : focusStyle,
);
const style = focusVisible
? resolveFocusedContentStyle(baseStyle, theme, focusConfig)
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/widgets/__tests__/formWidgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
shouldShowError,
} from "../field.js";
import {
RADIO_DISABLED_SELECTED,
RADIO_DISABLED_UNSELECTED,
RADIO_SELECTED,
RADIO_UNSELECTED,
buildRadioOptionText,
Expand Down Expand Up @@ -244,11 +246,21 @@ describe("radioGroup widget utilities", () => {
assert.equal(getRadioIndicator(true), RADIO_SELECTED);
});

test("getRadioIndicator disabled states", () => {
assert.equal(getRadioIndicator(false, true), RADIO_DISABLED_UNSELECTED);
assert.equal(getRadioIndicator(true, true), RADIO_DISABLED_SELECTED);
});

test("buildRadioOptionText", () => {
assert.equal(buildRadioOptionText(false, "Free"), `${RADIO_UNSELECTED} Free`);
assert.equal(buildRadioOptionText(true, "Free"), `${RADIO_SELECTED} Free`);
});

test("buildRadioOptionText preserves disabled indicators", () => {
assert.equal(buildRadioOptionText(false, "Pro", true), `${RADIO_DISABLED_UNSELECTED} Pro`);
assert.equal(buildRadioOptionText(true, "Pro", true), `${RADIO_DISABLED_SELECTED} Pro`);
});

test("findSelectedIndex finds correct index", () => {
assert.equal(findSelectedIndex("free", options), 0);
assert.equal(findSelectedIndex("enterprise", options), 2);
Expand Down
Loading