Skip to content

Addon-Docs: Improve object control JSON editor accessibility#34108

Open
anchmelev wants to merge 25 commits intostorybookjs:nextfrom
anchmelev:fix/issue-24150-object-control-json-a11y
Open

Addon-Docs: Improve object control JSON editor accessibility#34108
anchmelev wants to merge 25 commits intostorybookjs:nextfrom
anchmelev:fix/issue-24150-object-control-json-a11y

Conversation

@anchmelev
Copy link
Copy Markdown
Contributor

@anchmelev anchmelev commented Mar 11, 2026

Part of #24150

What I did

Improved accessibility of the Object control JSON editor in docs controls:

  • Added an explicit sr-only label for the raw JSON textarea.
  • Added aria-invalid and conditional aria-describedby for parse errors.
  • Added a polite live region (role="status", aria-live="polite") to announce JSON parse errors.
  • Added ariaDescription to the “Edit as JSON” toggle button.
  • Added unit tests for the new accessibility behavior.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

  1. Run Storybook UI for this repo (cd code && yarn storybook:ui).
  2. Open the Object control story (e.g. .../blocks-controls-object--object).
  3. Click the “Edit object as JSON” toggle button.
  4. Enter invalid JSON (e.g. {"label":) and blur the textarea.
  5. Verify an error message appears and is announced as a live status update.
  6. Verify textarea has invalid state (aria-invalid=true) and references the error via aria-describedby.
  7. Enter valid JSON (e.g. {"label":"updated"}) and blur again.
  8. Verify the error message disappears and invalid/error associations are cleared.

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Summary by CodeRabbit

  • New Features

    • JSON editor now shows inline validation errors and associates them with the input for assistive tech.
    • Edit-toggle includes clearer accessible description.
  • Bug Fixes

    • Error state resets when closing/reopening raw JSON editor; aria-invalid and aria-describedby are cleared appropriately.
    • Prevents change events from firing while JSON is invalid.
  • Tests

    • Added interactive tests validating JSON error display, reset behavior, and accessibility attributes.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds structured raw-JSON editing UI to the Object control with inline parse-error display and improved ARIA attributes; resets parse errors when switching modes or when the JSON string changes. Also adds Storybook play tests covering validation and error-reset flows.

Changes

Cohort / File(s) Summary
JSON Editor Error Handling & Accessibility
code/addons/docs/src/blocks/controls/Object.tsx
Introduces RawInputWrapper and ErrorMessage, adds controlId/jsonErrorId, applies aria-invalid/aria-describedby on the raw textarea, resets parseError when entering raw mode or when jsonString changes, and updates toggle button ARIA description.
JSON Editor Test Stories
code/addons/docs/src/blocks/controls/Object.stories.tsx
Updates storybook/test imports and adds JsonEditorValidation and JsonEditorErrorReset stories with play flows that validate error message rendering, ARIA relationships (aria-invalid, aria-describedby), preventing onChange on invalid JSON, and clearing errors when reopening editor.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs


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
Copy Markdown
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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@code/addons/docs/src/blocks/controls/Object.tsx`:
- Around line 233-257: parseError persists across remounts of the uncontrolled
RawInput, so when the textarea is rebuilt from jsonString the old error state
stays shown; fix this by clearing the parseError when the raw editor mounts or
when jsonString/forceVisible changes. Add a useEffect in the component that
contains rawJSONForm (or in the same component using parseError state) that
calls setParseError(undefined) with a dependency on jsonString and/or
forceVisible (or runs once on mount) so that when RawInput (key={jsonString}) is
remounted the aria-invalid and ErrorMessage are cleared until new validation
runs via updateRaw.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b967165c-5819-42ca-af3c-495ca6cf461b

📥 Commits

Reviewing files that changed from the base of the PR and between 1ba2d07 and fffdb1e.

📒 Files selected for processing (2)
  • code/addons/docs/src/blocks/controls/Object.test.tsx
  • code/addons/docs/src/blocks/controls/Object.tsx

Comment thread code/addons/docs/src/blocks/controls/Object.tsx
Copy link
Copy Markdown
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.

🧹 Nitpick comments (3)
code/core/src/component-testing/components/InteractionsPanel.test.tsx (1)

16-39: Keep calls in sync with the overridden interaction state.

createProps() hardcodes calls from CallStates.DONE, but the running/errored path only swaps interactions. That leaves callsById out of sync with the rendered rows and can mask regressions in MethodCall or exception rendering.

♻️ One way to keep the fixture coherent
-const createProps = (overrides: Partial<InteractionsPanelProps> = {}): InteractionsPanelProps => ({
+const createProps = (
+  callState: CallStates = CallStates.DONE,
+  overrides: Partial<InteractionsPanelProps> = {}
+): InteractionsPanelProps => ({
   storyUrl: 'http://localhost:6006/?path=/story/core-component-test-basics--step',
   status: 'completed',
   controls: {
     start: vi.fn(),
     back: vi.fn(),
     goto: vi.fn(),
     next: vi.fn(),
     end: vi.fn(),
     rerun: vi.fn(),
   },
   controlStates: {
     detached: false,
     start: true,
     back: true,
     goto: true,
     next: true,
     end: true,
   },
-  interactions: getInteractions(CallStates.DONE),
-  calls: new Map(getCalls(CallStates.DONE).map((call) => [call.id, call])),
+  interactions: getInteractions(callState),
+  calls: new Map(getCalls(callState).map((call) => [call.id, call])),
   api: { openInEditor: vi.fn() } as unknown as API,
   ...overrides,
 });
-      createProps({
-        status: 'playing',
-        interactions: getInteractions(CallStates.ACTIVE),
-      })
+      createProps(CallStates.ACTIVE, { status: 'playing' })
-          {...createProps({
-            status: 'errored',
-            interactions: getInteractions(CallStates.ERROR),
-          })}
+          {...createProps(CallStates.ERROR, { status: 'errored' })}

Also applies to: 83-102

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/component-testing/components/InteractionsPanel.test.tsx` around
lines 16 - 39, createProps currently builds interactions from a
possibly-overridden CallStates but always constructs calls from CallStates.DONE,
causing calls (the Map passed as calls/callsById) to diverge from interactions;
update createProps so calls are derived from the same state as interactions
(e.g., compute const state = overrides.state ?? CallStates.DONE or infer state
from overrides.interactions, then call getCalls(state) when building calls) so
getCalls and getInteractions use the same CallStates and the MethodCall rows and
exception rendering remain in sync.
code/core/src/component-testing/components/InteractionsPanel.tsx (1)

145-156: Derive the spoken error state from the same sources as the visible error UI.

statusAnnouncement only keys off hasException, but this component can also surface failures via hasResultMismatch, caughtException, and unhandledErrors. A shared hasErrors flag here would keep the live-region copy aligned with what the panel actually renders.

♻️ Possible simplification
-    const statusAnnouncement =
+    const hasErrors =
+      hasException ||
+      hasResultMismatch ||
+      !!caughtException ||
+      (unhandledErrors?.length ?? 0) > 0;
+    const statusAnnouncement =
       status === 'rendering'
         ? 'Component test is rendering.'
         : status === 'playing'
           ? 'Component test is running.'
           : status === 'errored'
             ? 'Component test failed.'
             : status === 'aborted'
               ? 'Component test was aborted.'
-              : hasException
+              : hasErrors
                 ? 'Component test completed with errors.'
                 : 'Component test completed successfully.';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/component-testing/components/InteractionsPanel.tsx` around
lines 145 - 156, Create a unified error flag (e.g., hasErrors) in the
InteractionsPanel and set it to true when any of hasException,
hasResultMismatch, caughtException, or unhandledErrors is truthy; then update
statusAnnouncement to use hasErrors instead of only hasException so the
spoken/live-region state matches the visible error UI (update the logic around
statusAnnouncement and reference the existing symbols: statusAnnouncement,
hasException, hasResultMismatch, caughtException, unhandledErrors, hasErrors).
code/core/src/component-testing/components/Interaction.tsx (1)

142-151: Expand aria-label for non-step calls to include path and args context.

aria-label currently falls back to only call.method for non-step calls, discarding the path and args that <MethodCall /> renders visually. For example, a call like userEvent.click(button Click) gets the aria-label "click", losing all context about the path and target. The same fallback is also used by the nested-toggle label at lines 258-260.

Consider building the aria-label from call.path and call.args for non-step calls to match the visual rendering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/core/src/component-testing/components/Interaction.tsx` around lines 142
- 151, getInteractionLabel currently returns only call.method for non-step
calls, losing the path and args context that <MethodCall /> shows; update
getInteractionLabel(Call) to build a descriptive aria-label by concatenating
call.method with a representation of call.path (e.g., join path segments) and
call.args (stringify/map args to readable tokens) so aria-label matches the
visual "<MethodCall />" output, and apply the same construction for the
nested-toggle label that uses getInteractionLabel so both places include path
and args context.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@code/core/src/component-testing/components/Interaction.tsx`:
- Around line 142-151: getInteractionLabel currently returns only call.method
for non-step calls, losing the path and args context that <MethodCall /> shows;
update getInteractionLabel(Call) to build a descriptive aria-label by
concatenating call.method with a representation of call.path (e.g., join path
segments) and call.args (stringify/map args to readable tokens) so aria-label
matches the visual "<MethodCall />" output, and apply the same construction for
the nested-toggle label that uses getInteractionLabel so both places include
path and args context.

In `@code/core/src/component-testing/components/InteractionsPanel.test.tsx`:
- Around line 16-39: createProps currently builds interactions from a
possibly-overridden CallStates but always constructs calls from CallStates.DONE,
causing calls (the Map passed as calls/callsById) to diverge from interactions;
update createProps so calls are derived from the same state as interactions
(e.g., compute const state = overrides.state ?? CallStates.DONE or infer state
from overrides.interactions, then call getCalls(state) when building calls) so
getCalls and getInteractions use the same CallStates and the MethodCall rows and
exception rendering remain in sync.

In `@code/core/src/component-testing/components/InteractionsPanel.tsx`:
- Around line 145-156: Create a unified error flag (e.g., hasErrors) in the
InteractionsPanel and set it to true when any of hasException,
hasResultMismatch, caughtException, or unhandledErrors is truthy; then update
statusAnnouncement to use hasErrors instead of only hasException so the
spoken/live-region state matches the visible error UI (update the logic around
statusAnnouncement and reference the existing symbols: statusAnnouncement,
hasException, hasResultMismatch, caughtException, unhandledErrors, hasErrors).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b47552d2-4bac-4599-a363-ef54da681c57

📥 Commits

Reviewing files that changed from the base of the PR and between 1fd2f95 and 2e6bbc4.

📒 Files selected for processing (3)
  • code/core/src/component-testing/components/Interaction.tsx
  • code/core/src/component-testing/components/InteractionsPanel.test.tsx
  • code/core/src/component-testing/components/InteractionsPanel.tsx

@anchmelev anchmelev force-pushed the fix/issue-24150-object-control-json-a11y branch from 2e6bbc4 to 1fd2f95 Compare March 12, 2026 02:11
@anchmelev
Copy link
Copy Markdown
Contributor Author

Hi @Sidnioulz, gentle ping on this PR as part of #24150.
It improves accessibility for the object control JSON editor by adding an explicit label, proper invalid/error associations, and live announcements for parse errors, with tests included.
All checks are passing — would appreciate your review when you have a moment.

@Sidnioulz Sidnioulz changed the title fix(addon-docs): improve object control JSON editor accessibility Addon-Docs: Improve object control JSON editor accessibility Mar 16, 2026
@Sidnioulz Sidnioulz self-assigned this Mar 16, 2026
@Sidnioulz Sidnioulz self-requested a review March 16, 2026 18:52
Comment thread code/addons/docs/src/blocks/controls/Object.tsx Outdated
Comment thread code/addons/docs/src/blocks/controls/Object.test.tsx
@storybook-app-bot
Copy link
Copy Markdown

storybook-app-bot Bot commented Mar 17, 2026

Package Benchmarks

Commit: 28f018c, ran on 15 April 2026 at 03:36:44 UTC

No significant changes detected, all good. 👏

@anchmelev
Copy link
Copy Markdown
Contributor Author

Hey @Sidnioulz
FYI after the latest push, the remaining CI failures appear unrelated to this change. This PR only touches code/addons/docs/src/blocks/controls/Object.tsx and Object.stories.tsx, while the failing checks are in portable-stories (save-from-controls) and SvelteKit sandbox jobs.

Copy link
Copy Markdown
Member

@Sidnioulz Sidnioulz left a comment

Choose a reason for hiding this comment

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

Thanks for the PR! Great work with the ARIA attributes!

Let's discuss the best approach for the ToggleButton, and I'll then be able to approve.

Comment thread code/addons/docs/src/blocks/controls/Object.tsx

const ErrorMessage = styled.p(({ theme }) => ({
margin: 0,
color: theme.color.negative,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: please make sure to test this on light and dark themes.

In other areas of the codebase, we have:

color: theme.base === 'dark' ? theme.color.negative : theme.color.negativeText

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.

Good call, I aligned the error text color with the existing light/dark theme pattern and checked it in both themes.

Comment thread code/addons/docs/src/blocks/controls/Object.tsx
@anchmelev anchmelev force-pushed the fix/issue-24150-object-control-json-a11y branch from 63f3818 to 3890b50 Compare March 25, 2026 03:35
@anchmelev anchmelev requested a review from Sidnioulz March 25, 2026 04:40
# Conflicts:
#	code/addons/docs/src/blocks/controls/Object.tsx
#	code/core/src/components/components/Button/Button.stories.tsx
#	code/core/src/components/components/Button/Button.tsx
…fix/issue-24150-object-control-json-a11y

# Conflicts:
#	code/core/src/components/components/Button/Button.tsx
#	code/core/src/components/components/Button/helpers/InteractiveTooltipWrapper.tsx
…fix/issue-24150-object-control-json-a11y
@anchmelev
Copy link
Copy Markdown
Contributor Author

Hi @Sidnioulz, gentle follow-up on this PR.
Please let me know if there’s any additional work needed from my side to move it forward for merge.
Really appreciate your time and feedback.

Comment thread code/addons/docs/src/blocks/controls/Object.tsx
Copy link
Copy Markdown
Member

@Sidnioulz Sidnioulz left a comment

Choose a reason for hiding this comment

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

I ended up finding a bug that shows up when using the control and switching from empty to filled.

Go to https://635781f3500dd2c49e189caf-qmqwwhnlky.chromatic.com/?path=/story/addons-docs-components-argstable-argstable--all-controls&globals=;sb_theme:dark, and click the object control to set it.

Besides that, we need to find a context where a filled Object control sits side by side with other filled controls to check if spacings look good (I'd prefer to have a new ArgsTable AllControlsFilled story for that, so we have visual regression testing).

@anchmelev
Copy link
Copy Markdown
Contributor Author

I ended up finding a bug that shows up when using the control and switching from empty to filled.

Go to https://635781f3500dd2c49e189caf-qmqwwhnlky.chromatic.com/?path=/story/addons-docs-components-argstable-argstable--all-controls&globals=;sb_theme:dark, and click the object control to set it.

Besides that, we need to find a context where a filled Object control sits side by side with other filled controls to check if spacings look good (I'd prefer to have a new ArgsTable AllControlsFilled story for that, so we have visual regression testing).

Thanks, I fixed the empty -> filled bug and added the requested story coverage.

I tracked this back to afbdb47 (Update Object control live announcement). AllControlsObjectSet now covers the transition from an empty Object control to a filled one, and AllControlsFilled provides the side-by-side filled-controls context for visual regression checks. I also updated the interaction to target the specific Object control via a stable id so the story test does not depend on duplicate "Set object" buttons.

Separate note: one of the current fork checks appears to be unrelated to this PR.

The formatting job is currently running cd code && yarn lint:fmt, but code/package.json does not define a lint:fmt script, so that step fails and sends notification emails. This looks like a CI configuration issue. If you want me to fix this, let me know.

<Wrapper>
{isObjectOrArray && (
<RawButton
disabled={readonly}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@anchmelev we need to preserve the readonly bit. Sometimes we render ArgsTable in a non-editable way.

Comment thread AGENTS.md

- **Base branch**: `next` (all PRs should target `next`, not `main`)
- **Node.js**: `22.21.1` (see `.nvmrc`)
- **Node.js**: `22.22.1` (see `.nvmrc`)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you know why this changed? This shouldn't have happened on that PR.

Comment on lines 8 to -23
@@ -15,12 +13,6 @@ export * from '@storybook/react';
export * from './portable-stories.ts';
export * from './types.ts';

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
declare module '@storybook/nextjs-vite/vite-plugin' {
export const storybookNextJsPlugin: typeof vitePluginStorybookNextJs;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We know we have some failing type checks in the monorepo that are annoying, and that can trip up LLMs. Sometimes, these only fail locally with specific IDEs, etc, but are fine in CI.

Those need to be handled in separate PRs. Sometimes they are false positives; and also, they're different from UI/a11y issues so I wouldn't be the right person to review NextJS changes.

Could you please remove it from this PR? Feel free to make a followup PR if you'd like.

Comment on lines -5 to -7
const vitePluginStorybookNextjs = require('vite-plugin-storybook-nextjs');
const vitePluginStorybookNextjs =
require('vite-plugin-storybook-nextjs') as typeof vitePluginStorybookNextJs;

export const storybookNextJsPlugin = vitePluginStorybookNextjs;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same here, for a separate PR please 🙏

{/* We don't let react-aria set an aria-describedby attribute because it clashes with our intention to explicitly set an aria-label that can be different from the tooltip copy. Some screenreaders would announce the label AND description if we also allowed aria-describedby, which would decrease usability. When a component has its own explicit description, we preserve that ID instead of the tooltip's. */}
<Focusable>
{/* @ts-expect-error: We have to override aria-describedby and this is the only way we can do it (undefined won't work and an empty string will result in DOM pollution). */}
{React.cloneElement(child, { 'aria-describedby': overrideAriaDescribedby ?? null })}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for fixing that! Good catch!

ariaDescription = undefined,
}) => {
const { ariaDescriptionAttrs, AriaDescription } = useAriaDescription(ariaDescription);
const childWithAriaDescription = ariaDescriptionAttrs['aria-describedby']
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Aren't you also cloning the element in TooltipProvider? Isn't it redundant to do it twice?

Comment on lines +34 to +37
const tooltipTriggerChild = childWithAriaDescription as ReactElement<
DOMAttributes<Element>,
string
>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This type cast feels problematic to me. We should normally have a single element child so that TooltipProvider Focusable knows it has a single child. If that requirement gets forgotten in the future, a type cast would hide a newly introduced regression.

Maybe a type guard on cloneElement could help ensure childWithAriaDescription is already correctly typed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants