Skip to content

Conversation

@kotAPI
Copy link
Collaborator

@kotAPI kotAPI commented Sep 7, 2025

Summary

  • add roving focus, controlled selection, form support, and data state/disabled attributes to RadioGroup primitives
  • cover RadioGroup behaviors including keyboard navigation, form submission, and RTL with new tests
  • remove role-specific aria-selected coupling from roving focus items

Testing

  • npm test src/core/utils/RovingFocusGroup/tests/RovingFocusGroup.behavior.test.tsx
  • npm test src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx
  • npm test src/components/ui/RadioCards/tests/RadioCards.test.tsx
  • npm test

Summary by CodeRabbit

  • Bug Fixes

    • RadioGroup now reliably submits the selected value and supports required validation without visual impact.
    • Disabled groups/items are consistently indicated and skipped for focus and form submission.
    • Improved accessibility semantics: checked/unchecked state and disabled indicators are conveyed more accurately to assistive tech.
  • Tests

    • Added comprehensive RadioGroup behavior tests (keyboard/RTL navigation, controlled/default handling, form submission, asChild, accessibility).
    • Updated RadioCards test to match hidden-input behavior.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 7, 2025

Walkthrough

Updates RadioGroup primitives: expands provider scope in items, switches item state attributes to data-state/data-disabled and aria-checked, changes hidden input handling (uses type="hidden" plus a conditional invisible radio for required), composes RovingFocusGroup via asChild, and adds comprehensive RadioGroup behavior tests; tweaks RadioCards test to expect type="hidden".

Changes

Cohort / File(s) Summary
RadioGroup primitives
src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx, src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveItem.tsx
Root: adds data-disabled, composes RovingFocusGroup.Root via asChild, replaces the previous hidden radio with a type="hidden" input and conditionally renders an additional invisible radio when required and a value is selected. Item: expands provider scope to wrap the item and button, uses itemSelected for aria-checked, replaces data-checked with data-state (checked/unchecked), adds data-disabled, and sets aria-selected={undefined} on the button.
RadioGroup tests
src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx
New behavior tests covering keyboard navigation (LTR/RTL), controlled vs default value behavior, form submission, skipping disabled items, data-* attributes, accessibility (axe), asChild semantics, and ref propagation.
RadioCards tests
src/components/ui/RadioCards/tests/RadioCards.test.tsx
Test updated: queries input[type="hidden"] for form submission value instead of a hidden radio input; asserts presence and value.
RadioGroup primitive tests
src/core/primitives/RadioGroup/tests/RadioGroupPrimtive.test.tsx
Test update: selects the first element among elements with the selected display value to locate the hidden input; asserts the input name.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant RG as RadioGroup.Root
  participant RFG as RovingFocusGroup.Root
  participant Item as RadioGroup.Item
  participant Hidden as Hidden Input (type=hidden)
  participant Invisible as Invisible Radio (aria-hidden)
  participant Form

  Note over RG,RFG: Mount
  RG->>RFG: render asChild (root)
  RFG->>Item: register items (roving focus)

  Note over User,Item: Interaction
  User->>Item: click / arrow keys
  Item-->>RG: request value change
  RG-->>Hidden: update value
  RG-->>Item: update context (itemSelected → data-state, aria-checked)

  alt name && required && selectedValue != ""
    RG->>Invisible: render invisible radio (checked, aria-hidden)
  else
    RG--x Invisible: not rendered
  end

  Note over Form,Hidden: Submit
  User->>Form: submit
  Form->>Hidden: read name/value
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • GoldGroove06

Pre-merge checks (3 passed)

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The current title “feat: enhance RadioGroup behavior and accessibility” precisely captures the primary purpose of this changeset by indicating that it adds new behavior (roving focus, keyboard navigation, form integration) and accessibility improvements to the RadioGroup component without extraneous detail or ambiguity.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.

Poem

I nibble on the chosen tune,
A hop, a click beneath the moon—
Hidden seeds in silent rows,
Values carried where the hare goes.
I mark the checked with tiny paws. 🐇

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kotapi/add-roving-focus-support-to-radiogroup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

Codex Review: Here are some suggestions.

Reply with @codex fix comments to fix any unresolved comments.

About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you open a pull request for review, mark a draft as ready, or comment "@codex review". If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex fix this CI failure" or "@codex address that feedback".

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
src/components/ui/RadioCards/tests/RadioCards.test.tsx (1)

110-113: Selector update looks right; add a quick name assertion.

This aligns with the switch to a hidden input. Consider also asserting the name to catch regressions.

Apply this diff:

-        expect(input).toBeInTheDocument();
+        expect(input).toBeInTheDocument();
+        expect(input).toHaveAttribute('name', 'test-group');
         expect(input?.value).toBe(options[0].value);
src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveItem.tsx (1)

27-41: Minor ARIA cleanups.

  • With a native disabled attribute on the button, aria-disabled is redundant.
  • Prefer aria-required only on the radiogroup root; per-item aria-required is unnecessary.

Apply this diff:

-                        aria-disabled={groupDisabled || disabled}
+                        aria-disabled={groupDisabled || disabled ? true : undefined}
-                        aria-required={required}
+                        aria-required={undefined}

(Or drop aria-disabled entirely since the button is actually disabled.)

src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx (3)

1-9: De-duplicate WCAG tags: import from setupTests instead of redefining.

Avoid drift with the central config (wcag21a/aa in setupTests vs wcag2a/aa here).

 import '@testing-library/jest-dom';
 import userEvent from '@testing-library/user-event';
-import axe from 'axe-core';
+import axe from 'axe-core';
 import RadioGroup from '../RadioGroup';
-
-const ACCESSIBILITY_TEST_TAGS = ['wcag2a', 'wcag2aa'];
+// Reuse shared accessibility tag configuration
+import { ACCESSIBILITY_TEST_TAGS } from '../../../setupTests';

Please verify the relative path resolves under your Jest config.


36-62: Isolate renders within the test to reduce cross-interaction.

Rendering two groups in the same test body is fine, but unmounting the first avoids incidental interactions and keeps queries scoped.

-        render(<Controlled />);
+        const { unmount } = render(<Controlled />);
         const user = userEvent.setup();
         await user.tab();
         await user.keyboard('{ArrowRight}');
         expect(screen.getByTestId('c-two')).toHaveAttribute('aria-checked', 'true');
         await user.keyboard('{ArrowRight}');
         expect(screen.getByTestId('c-three')).toHaveAttribute('aria-checked', 'true');
 
-        render(
+        unmount();
+        render(
             <RadioGroup.Root defaultValue="two">
                 <RadioGroup.Item value="one" data-testid="u-one">one</RadioGroup.Item>
                 <RadioGroup.Item value="two" data-testid="u-two">two</RadioGroup.Item>
             </RadioGroup.Root>
         );

104-115: Optional: use jest-axe matcher for cleaner a11y assertions.

Simplifies assertions and improves error messages.

-import axe from 'axe-core';
+import { axe, toHaveNoViolations } from 'jest-axe';
+expect.extend(toHaveNoViolations);
@@
-        const results = await axe.run(container, { runOnly: { type: 'tag', values: ACCESSIBILITY_TEST_TAGS } });
-        expect(results.violations).toHaveLength(0);
+        const results = await axe(container, { runOnly: { type: 'tag', values: ACCESSIBILITY_TEST_TAGS } });
+        expect(results).toHaveNoViolations();
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 260b49d and a499df3.

📒 Files selected for processing (6)
  • src/components/ui/RadioCards/tests/RadioCards.test.tsx (1 hunks)
  • src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx (1 hunks)
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveItem.tsx (1 hunks)
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (2 hunks)
  • src/core/utils/RovingFocusGroup/fragments/RovingFocusItem.tsx (0 hunks)
  • src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx (0 hunks)
💤 Files with no reviewable changes (2)
  • src/core/utils/RovingFocusGroup/fragments/RovingFocusItem.tsx
  • src/core/utils/RovingFocusGroup/fragments/RovingFocusRoot.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx (1)
src/setupTests.ts (1)
  • ACCESSIBILITY_TEST_TAGS (3-3)
🪛 GitHub Check: lint
src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx

[failure] 118-118:
Component definition is missing display name

🪛 GitHub Actions: Lint
src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx

[error] 118-118: ESLint: Component definition is missing display name. (react/display-name). Step: eslint --ext .js,.jsx,.ts,.tsx . --fix

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (1)
src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (1)

36-36: Adding data-disabled on the root is good for styling and tests.

No issues; this makes disabled state easily selectable without changing ARIA.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/components/ui/RadioCards/tests/RadioCards.test.tsx (1)

108-113: Tighten assertions and clarify test title

Make the test more precise and future-proof:

  • Remove "radio" from the title (it’s now a hidden input).
  • Assert the name attribute to ensure form wiring.
  • Ensure only one hidden input exists for the group.
  • Verify the hidden input’s value updates on selection change.

Apply:

-    it('renders hidden radio input for form submission', () => {
+    it('renders hidden input for form submission', () => {
       const { container } = renderRadioCards({ defaultValue: options[0].value, name: 'test-group' });
-      const input = container.querySelector('input[type="hidden"]') as HTMLInputElement | null;
+      const input = container.querySelector('input[type="hidden"][name="test-group"]') as HTMLInputElement | null;
       expect(input).toBeInTheDocument();
+      expect(input?.name).toBe('test-group');
+      expect(container.querySelectorAll('input[type="hidden"][name="test-group"]')).toHaveLength(1);
       expect(input?.value).toBe(options[0].value);
+      fireEvent.click(screen.getByTestId('radio-item-16-core CPU'));
+      expect(input?.value).toBe('16-core CPU');
     });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a499df3 and 49bd24a.

📒 Files selected for processing (4)
  • src/components/ui/RadioCards/tests/RadioCards.test.tsx (1 hunks)
  • src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx (1 hunks)
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveItem.tsx (1 hunks)
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveItem.tsx
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx
🔇 Additional comments (1)
src/components/ui/RadioCards/tests/RadioCards.test.tsx (1)

108-113: LGTM: hidden input assertion matches new form-control semantics

Switching the query to input[type="hidden"] and asserting its value aligns with the updated RadioGroup integration for form submission.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (1)

37-45: asChild is applied to a non-DOM child (Context.Provider); injected roving props are dropped

With asChild, Root clones its only child to inject DOM props. Here the only child is RadioGroupContext.Provider, so those props won’t land on a real element and may cause lost semantics and React warnings. Either wrap the Primitive.div with Root asChild (preferred) or drop asChild here.

Apply this minimal diff (preferred restructuring):

-            <Primitive.div ref={ref} {...props} aria-required={required} role='radiogroup' aria-disabled={groupDisabled} data-disabled={groupDisabled ? '' : undefined}>
-                <RovingFocusGroup.Root dir={dir} orientation={orientation} loop={loop} asChild>
-                    <RadioGroupContext.Provider value={sendItems}>
+            <RovingFocusGroup.Root dir={dir} orientation={orientation} loop={loop} asChild>
+              <Primitive.div ref={ref} {...props} aria-required={required} role='radiogroup' aria-disabled={groupDisabled} data-disabled={groupDisabled ? '' : undefined}>
+                <RadioGroupContext.Provider value={sendItems}>
                       <RovingFocusGroup.Group>
                         {children}
                       </RovingFocusGroup.Group>
-                    </RadioGroupContext.Provider>
-                </RovingFocusGroup.Root>
+                </RadioGroupContext.Provider>
+              </Primitive.div>
+            </RovingFocusGroup.Root>

Alternatively (least invasive), just drop asChild:

-                <RovingFocusGroup.Root dir={dir} orientation={orientation} loop={loop} asChild>
+                <RovingFocusGroup.Root dir={dir} orientation={orientation} loop={loop}>
🧹 Nitpick comments (1)
src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (1)

36-36: Add aria-orientation (and dir) to the radiogroup element

Improves AT semantics and CSS targeting. Only set aria-orientation when not "both".

-            <Primitive.div ref={ref} {...props} aria-required={required} role='radiogroup' aria-disabled={groupDisabled} data-disabled={groupDisabled ? '' : undefined}>
+            <Primitive.div
+              ref={ref}
+              {...props}
+              role='radiogroup'
+              aria-required={required}
+              aria-disabled={groupDisabled}
+              aria-orientation={orientation === 'both' ? undefined : orientation}
+              dir={dir}
+              data-disabled={groupDisabled ? '' : undefined}
+            >
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 49bd24a and e0b9bb2.

📒 Files selected for processing (2)
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveItem.tsx (1 hunks)
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveItem.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: build
  • GitHub Check: Analyze (javascript)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (1)

46-76: Required still doesn’t block empty submissions; also causes duplicate form entries when selected.

  • The sentinel radio only renders when a value is selected, so required does not prevent submitting with no selection.
  • When a value is selected and required is true, both the hidden input and the sentinel radio submit the same name, yielding duplicate entries.

Fix by rendering either the hidden input (not required path) or the sentinel radio (required path), and render the sentinel even when no value is selected (unchecked to enforce the constraint):

-                {name && (
-                    <>
-                        <input
-                            type='hidden'
-                            name={name}
-                            value={selectedValue}
-                            disabled={groupDisabled}
-                        />
-                        {required && selectedValue !== '' && (
-                            <input
-                                type='radio'
-                                name={name}
-                                value={selectedValue}
-                                checked
-                                onChange={() => {}}
-                                disabled={groupDisabled}
-                                required
-                                aria-hidden='true'
-                                tabIndex={-1}
-                                style={{
-                                    position: 'absolute',
-                                    width: 0,
-                                    height: 0,
-                                    opacity: 0,
-                                    pointerEvents: 'none',
-                                    margin: 0
-                                }}
-                            />
-                        )}
-                    </>
-                )}
+                {name && (
+                  <>
+                    {!required && selectedValue !== '' && (
+                      <input
+                        type='hidden'
+                        name={name}
+                        value={selectedValue}
+                        disabled={groupDisabled}
+                      />
+                    )}
+                    {required && (
+                      <input
+                        type='radio'
+                        name={name}
+                        value={selectedValue}
+                        checked={selectedValue !== ''}
+                        onChange={() => {}}
+                        disabled={groupDisabled}
+                        required
+                        aria-hidden='true'
+                        tabIndex={-1}
+                        style={{
+                          position: 'absolute',
+                          width: 0,
+                          height: 0,
+                          opacity: 0,
+                          pointerEvents: 'none',
+                          margin: 0
+                        }}
+                      />
+                    )}
+                  </>
+                )}
🧹 Nitpick comments (2)
src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (1)

36-37: Expose aria-orientation for assistive tech.

Set aria-orientation on the radiogroup when not “both” for better SR hints.

-            <Primitive.div ref={ref} {...props} aria-required={required} role='radiogroup' aria-disabled={groupDisabled} data-disabled={groupDisabled ? '' : undefined}>
+            <Primitive.div
+              ref={ref}
+              {...props}
+              role='radiogroup'
+              aria-required={required}
+              aria-disabled={groupDisabled}
+              aria-orientation={orientation !== 'both' ? orientation : undefined}
+              data-disabled={groupDisabled ? '' : undefined}
+            >
src/core/primitives/RadioGroup/tests/RadioGroupPrimtive.test.tsx (1)

75-76: Avoid brittle index-based query; target the hidden input explicitly.

Using [0] couples the test to DOM order and may break if a sentinel radio is present. Filter by selector.

-        // name is set on the hidden input, which is the first element with the selected value
-        const hiddenInput = screen.getAllByDisplayValue('a')[0];
+        // name is set on the hidden input; select it explicitly
+        const hiddenInput = screen.getByDisplayValue('a', { selector: 'input[type="hidden"]' });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e0b9bb2 and 82c23df.

📒 Files selected for processing (3)
  • src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx (1 hunks)
  • src/core/primitives/RadioGroup/fragments/RadioGroupPrimitiveRoot.tsx (2 hunks)
  • src/core/primitives/RadioGroup/tests/RadioGroupPrimtive.test.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/ui/RadioGroup/tests/RadioGroup.behavior.test.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build

@kotAPI kotAPI merged commit ba10c12 into main Sep 10, 2025
7 checks passed
@kotAPI kotAPI deleted the kotapi/add-roving-focus-support-to-radiogroup branch September 10, 2025 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants