Skip to content

Screen reader Accessibility Improvements#8497

Open
matalvernaz wants to merge 21 commits intoSonarr:v5-developfrom
matalvernaz:accessibility/v5-screen-reader
Open

Screen reader Accessibility Improvements#8497
matalvernaz wants to merge 21 commits intoSonarr:v5-developfrom
matalvernaz:accessibility/v5-screen-reader

Conversation

@matalvernaz
Copy link
Copy Markdown

Sonarr is a great tool for me even though I'm completely blind. Right now the accessibility kinda sucks though so I wanted to try and make it better.

What's fixed

Buttons weren't labeled for my screen reader, aria labels have been added to fix that. Tables were unclear, and have been clarified a bit; at least from what I can tell from limited local testing. More images have been given alt text, so it doesn't just say iconx360.png or something equally unhelpful. Translations updated for aria labels.

Changes

  • IconButton: removed hardcoded 'Table Options Button' label that was announced on every icon button regardless of purpose; all call sites now have a meaningful aria-label; icon marked aria-hidden
  • Modal/ModalHeader: added role="dialog", aria-modal, and aria-labelledby so screen readers announce the modal title when it opens
  • MonitorToggleButton: added aria-label alongside title (NVDA reads title inconsistently)
  • TablePager: first/previous/next/last page links now have descriptive aria-label; pagination icons marked aria-hidden
  • PageSidebar: outer div changed to nav with aria-label so it appears as a navigation landmark
  • PageSidebarItem: aria-current="page" on the active link; sidebar icons marked aria-hidden
  • TableHeaderCell: aria-sort (ascending/descending/none) and scope="col" on sortable header cells; sort icon marked aria-hidden
  • en.json: added MainNavigation and PagerGoTo* translation keys

No visual changes.

matalvernaz and others added 3 commits March 26, 2026 11:42
- IconButton: remove hardcoded 'Table Options Button' aria-label that
  was announced on every icon button regardless of purpose; callers now
  supply their own aria-label via props; icon marked aria-hidden
- Modal/ModalHeader: add role="dialog", aria-modal, and aria-labelledby
  via a new ModalContext so screen readers announce the modal title on open
- MonitorToggleButton: add aria-label alongside title for NVDA compatibility
- TablePager: add descriptive aria-label to first/previous/next/last page
  links; pagination icons marked aria-hidden
- PageSidebar: change wrapper div to <nav> with aria-label so it appears
  as a navigation landmark
- PageSidebarItem: add aria-current="page" on the active link; sidebar
  icons marked aria-hidden
- TableHeaderCell: add aria-sort (ascending/descending/none) and scope="col"
  to sortable headers; sort icon marked aria-hidden
- en.json: add MainNavigation and PagerGoTo* translation keys

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Every IconButton that previously relied on the hardcoded fallback
'Table Options Button' label now has a meaningful, context-specific
aria-label. Labels use existing translate() keys where available and
plain strings where translate is not imported in the file.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Copy link
Copy Markdown
Member

@stevietv stevietv left a comment

Choose a reason for hiding this comment

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

Some strings are missing the translations, added the suggestions but please check they are the correct keys. also ensure translate is imported where it isn't

return (
<TableRowCell key={name} className={styles.details}>
<IconButton name={icons.INFO} onPress={handleDetailsPress} />
<IconButton name={icons.INFO} aria-label="Details" onPress={handleDetailsPress} />
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.

Suggested change
<IconButton name={icons.INFO} aria-label="Details" onPress={handleDetailsPress} />
<IconButton name={icons.INFO} aria-label={translate('Details')} onPress={handleDetailsPress} />

/>

<IconButton name={icons.ADD} onPress={handleAddPress} />
<IconButton name={icons.ADD} aria-label="Add" onPress={handleAddPress} />
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.

Suggested change
<IconButton name={icons.ADD} aria-label="Add" onPress={handleAddPress} />
<IconButton name={icons.ADD} aria-label={translate('Add')} onPress={handleAddPress} />

<IconButton
className={styles.editButton}
name={icons.EDIT}
aria-label="Edit"
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.

Suggested change
aria-label="Edit"
aria-label={translate('Edit')}

{isNew ? null : (
<IconButton
name={icons.REMOVE}
aria-label="Remove"
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.

Suggested change
aria-label="Remove"
aria-label={translate('Remove')}

onTableOptionChange={onTableOptionChange}
>
<IconButton name={icons.ADVANCED_SETTINGS} />
<IconButton name={icons.ADVANCED_SETTINGS} aria-label="Advanced Settings" />
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.

Suggested change
<IconButton name={icons.ADVANCED_SETTINGS} aria-label="Advanced Settings" />
<IconButton name={icons.ADVANCED_SETTINGS} aria-label={translate('AdvancedSettings')} />

assuming AdvancedSettings is a translation key

<SpinnerIconButton
name={icons.REFRESH}
spinningName={icons.REFRESH}
aria-label="Run"
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.

Suggested change
aria-label="Run"
aria-label={translate('Run')}


export const urlBase = window.Sonarr.urlBase;
export const apiRoot = '/api/v5'; // window.Sonarr.apiRoot;
export const apiRoot = window.Sonarr.apiRoot;
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.

why are you making this change in a screen reader accessibilty PR? we are not ready for this change until the full api is migrated to v5, this change essentially puts us back to v3.

Comment thread Dockerfile Outdated
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.

please remove this added docker file, I assume this is for your testing only.

matalvernaz and others added 3 commits March 27, 2026 00:07
- Revert fetchJson.ts apiRoot back to hardcoded /api/v5 per reviewer request; dynamic apiRoot change is premature until full v5 API migration
- Remove Dockerfile (was testing artifact, not intended for the PR)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Underlay link now announces series title (existing) or "Add {title}" (new)
- TVDB external link gets aria-label "View {title} on TVDB"; icon marked aria-hidden
- Added ViewSeriesOnTvdb translation key to en.json

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@matalvernaz
Copy link
Copy Markdown
Author

Hey, I just wanted to apologize for my last very messy pr, and for the AI code submissions in general. I have a bit of coding knowledge and access to an AI, but have never really used github which is a dangerous combo in the best case. I was adding a new show and getting annoyed, not really doing any research whatsoever before I made the PR in the first place. I really appreciate that people are willing to possibly look into adding this, and want to make it as easy as possible for everybody involved. If there's anything else I can do to help with that including screwing off and staying out of the way, please let me know.

@stevietv
Copy link
Copy Markdown
Member

Hey, I just wanted to apologize for my last very messy pr, and for the AI code submissions in general. I have a bit of coding knowledge and access to an AI, but have never really used github which is a dangerous combo in the best case. I was adding a new show and getting annoyed, not really doing any research whatsoever before I made the PR in the first place. I really appreciate that people are willing to possibly look into adding this, and want to make it as easy as possible for everybody involved. If there's anything else I can do to help with that including screwing off and staying out of the way, please let me know.

In my opinion the aria-improvements are very welcome, indeed sonarr doesn't have much A11y consideration in place.
It's important when contributing that you're able to test and verify that the changes you've made are working, especially when claude is doing the heavy lifting.

@matalvernaz
Copy link
Copy Markdown
Author

I'm testing locally on my instance with firefix, chrome and edge using both jaws and NVDA. I'm still adding missing labels when I find them but it's definitely better.

Copy link
Copy Markdown
Member

@markus101 markus101 left a comment

Choose a reason for hiding this comment

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

I agree, the changes are very welcomed, but Claude needs to have it's hand-held to actually do things correctly, there are several issues that would be caught by the linter if that was run prior to submission and some random things that were changed for seemingly no reason like the API root change Stevie caught and another because it decided UI settings was using the wrong path.

? icons.SORT_ASCENDING
: icons.SORT_DESCENDING;

const ariaSortValue = isSorting
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 fails linting. Do not nest ternary expressions

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.

Something like this would be much more readable.

  const ariaSortValue = useMemo(() => {
    if (!isSortable) {
      return undefined;
    }

    if (!isSorting) {
      return 'none';
    }

    return sortDirection === sortDirections.ASCENDING
      ? 'ascending'
      : 'descending';
  }, [isSorting, sortDirection, isSortable]);


const ariaSortValue = isSorting
? sortDirection === sortDirections.ASCENDING
? ('ascending' as const)
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.

Why is everything using as const?

}

const PATH = '/settings/ui';
const PATH = '/config/ui';
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 is wrong.

Comment thread frontend/src/Components/Table/Table.tsx Outdated
onTableOptionChange={onTableOptionChange}
>
<IconButton name={icons.ADVANCED_SETTINGS} />
<IconButton name={icons.ADVANCED_SETTINGS} aria-label="Advanced Settings" />
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 should be translated.

matalvernaz and others added 3 commits March 28, 2026 16:31
Addresses PR review from markus101: nested ternary failed linting,
replaced with useMemo block per suggested pattern.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Addresses PR review from markus101: hardcoded string replaced with
translate('AdvancedSettings'); import added.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The /config/ui path was incorrect; /settings/ui is consistent with all
other settings hooks (general, mediamanagement, naming, etc.).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@matalvernaz
Copy link
Copy Markdown
Author

I've addressed these fixes, and everything seems to be working in my test instance. I've tested with chrome and firefox, jaws and NVDA. Everything seems to be working and the logs aren't screaming at me, so there's hope.

@stevietv
Copy link
Copy Markdown
Member

I've addressed these fixes, and everything seems to be working in my test instance. I've tested with chrome and firefox, jaws and NVDA. Everything seems to be working and the logs aren't screaming at me, so there's hope.

just want to make sure you also ran yarn lint to check it complies?

matalvernaz and others added 2 commits March 29, 2026 00:59
Remove event.preventDefault() from label onClick so screen readers
receive a real DOM state change when a checkbox is toggled. ShiftKey
is captured via a ref on the label click (which fires before onChange)
so shift-range-select on table rows continues to work.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Auto-fix prettier formatting and import sort order across all files
flagged by yarn lint.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@matalvernaz
Copy link
Copy Markdown
Author

Yes, just ran that and fixed a bunch of formatting issues.

@markus101
Copy link
Copy Markdown
Member

Please remove all the recent changes around build scripts and CI. GitHub actions already runs linting as part of the build process.

@matalvernaz
Copy link
Copy Markdown
Author

Done. Sorry about that, claude ran along with an idea I wasn't wanting.

Copy link
Copy Markdown
Member

@markus101 markus101 left a comment

Choose a reason for hiding this comment

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

Shift clicking ranges of check inputs is broken and I'm not really sure what the purpose of the modal header ID is.

import styles from './CheckInput.css';

interface ChangeEvent<T = Element> extends SyntheticEvent<T, MouseEvent> {
interface ChangeEvent<T = Element> extends React.SyntheticEvent<T, MouseEvent> {
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 should use SyntheticEvent directly, like it did before.

},
[isDisabled, toggleChecked]
);
const handleLabelClick = useCallback((event: React.MouseEvent) => {
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 with MouseEvent

[name, value, checkedValue, uncheckedValue, onChange]
);

const handleClick = useCallback(
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 broke using shift-clicking to change a whole range of check inputs, why was it changed?

return null;
}

const headerId = `${modalId}-header`;
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.

What is the purpose of the headerId? Seems like it's just passed to the header's div, but not clear what that actually provides.

If the modal needs to pass an ID down then it should just pass the ID, such as modalId, then consumers can use it as needed, right now if the body also needed an ID then you'd have to pass bodyId even though it's the same ID and a different suffix.

If this isn't needed then it should be removed though.

Copy link
Copy Markdown
Author

@matalvernaz matalvernaz Apr 15, 2026

Choose a reason for hiding this comment

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

the headerId is what aria-labelledby on the dialog points to (line 184), and ModalHeader sets the same id on the visible h2 through ModalContext. without that link the dialog has no accessible name, so screen readers just say "dialog" with nothing else.

I can rename it to something less generic like modalHeaderId if that reads better, or pass the raw modalId through context and have ModalHeader build its own suffix. let me know which you'd prefer and I'll change it.

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.

Gotcha, I think this actually makes sense as-is after looking at it again so we don't potentially create different header IDs in different places.

if (!isSortable) {
return undefined;
}
if (!isSorting) {
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.

New line needed before if block

if (!isSorting) {
return 'none';
}
return sortDirection === sortDirections.ASCENDING
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.

And before this return

{children}

{isSorting && <Icon name={sortIcon} className={styles.sortIcon} />}
{isSorting && (
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.

Use ternary instead of &&

CheckInput: revert label onClick to handleClick with preventDefault so
event.nativeEvent.shiftKey is read directly in onChange, restoring
shift-range selection on table check inputs. Drops the shiftKeyRef hack
and the React.SyntheticEvent/React.MouseEvent qualifications.

TableHeaderCell: blank lines before if/return inside the useMemo,
ternary with null instead of && for the sort icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants