fix: Address various a11y findings (#11977)

* A11y improvements

* fix: Accessibility improvements for sidebar, layout, and emoji icons

- Add role="main" to content area and role="contentinfo" to right sidebar
- Add aria-expanded to sidebar Disclosure toggle button
- Add nav landmark with aria-label to shared sidebar navigation
- Render SidebarLink as button instead of div when no link target
- Hide decorative emoji icons from screen readers (aria-hidden)
- Add aria-hidden to EmojiIcon SVG element

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Restore PopoverTrigger in FindAndReplace, add role to span

PopoverAnchor broke the find/replace popover. Revert to PopoverTrigger
and instead add role="button" and aria-label to the span so ARIA
attributes from Radix are valid on the element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Sidebar button styling

* fix: Use semantic list elements for References document list

Change the References list container from div to ul and wrap each
ReferenceListItem in an li element for proper screen reader semantics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Address PR review feedback for accessibility changes

- Heading buttons: switch from mousedown to click for keyboard access
- Heading fold: add aria-expanded attribute
- FindAndReplace: use real button element instead of span with role
- SidebarLink: branch render to avoid passing NavLink props to button
- Right sidebar: use role=complementary instead of contentinfo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Use translation hook for FindAndReplace, revert anchor click handler

- Use t() for aria-label in FindAndReplace button
- Revert heading anchor from click back to mousedown to avoid side effects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: Add ts-expect-error for styled NavLink overload mismatch

The spread props on the NavLink branch cause a TypeScript overload
mismatch that was previously suppressed. Re-add the suppression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-04-06 18:59:53 -04:00
committed by GitHub
parent ffe4e5c7e4
commit 64e75dac76
16 changed files with 125 additions and 55 deletions
+1
View File
@@ -48,6 +48,7 @@ const Layout = React.forwardRef(function Layout_(
<Content
auto
justify="center"
role="main"
$isResizing={ui.sidebarIsResizing}
$sidebarCollapsed={sidebarCollapsed}
$hasSidebar={!!sidebar}
+7 -1
View File
@@ -103,7 +103,13 @@ function Right({ children, border, className, skipInitialAnimation }: Props) {
};
return (
<Sidebar {...animationProps} $border={border} className={className}>
<Sidebar
{...animationProps}
$border={border}
className={className}
role="complementary"
aria-label="Right sidebar"
>
<Position style={style} column>
<ErrorBoundary>{children}</ErrorBoundary>
<ResizeBorder
+1 -1
View File
@@ -78,7 +78,7 @@ function SharedSidebar({ share }: Props) {
<Shortcut>{metaDisplay}K</Shortcut>
</SearchButton>
</TopSection>
<Section>
<Section as="nav" aria-label={t("Documents")}>
{share.collectionId ? (
<SharedCollectionLink
node={rootNode}
@@ -18,6 +18,7 @@ function Disclosure({ onClick, expanded, ...rest }: Props) {
size={20}
onClick={onClick}
aria-label={expanded ? t("Collapse") : t("Expand")}
aria-expanded={expanded}
{...rest}
>
<StyledCollapsedIcon $expanded={expanded} size={20} />
@@ -147,6 +147,49 @@ function SidebarLink(
const DisclosureComponent = icon ? HiddenDisclosure : Disclosure;
const innerContent = (
<>
<ContextMenu action={contextAction} ariaLabel={t("Link options")}>
<Content>
{hasDisclosure && (
<DisclosureComponent
expanded={expanded}
onClick={preventDefault}
onPointerDown={handleDisclosureClick}
tabIndex={-1}
/>
)}
{icon && <IconWrapper aria-hidden>{icon}</IconWrapper>}
<Label $ellipsis={ellipsis}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</ContextMenu>
{menu && <Actions $showActions={$showActions}>{menu}</Actions>}
</>
);
if (!to) {
return (
<Link
as={href ? "a" : "button"}
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
style={active ? activeStyle : style}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onDragEnter={handleMouseEnter}
href={href}
className={className}
ref={ref}
{...rest}
>
{innerContent}
</Link>
);
}
return (
<Link
$isActiveDrop={isActiveDrop}
@@ -159,31 +202,15 @@ function SidebarLink(
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onDragEnter={handleMouseEnter}
// @ts-expect-error exact does not exist on div
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
to={to!}
href={href}
className={className}
// @ts-expect-error spread props cause overload mismatch with styled NavLink
ref={ref}
{...rest}
>
<ContextMenu action={contextAction} ariaLabel={t("Link options")}>
<Content>
{hasDisclosure && (
<DisclosureComponent
expanded={expanded}
onClick={preventDefault}
onPointerDown={handleDisclosureClick}
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label $ellipsis={ellipsis}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
</ContextMenu>
{menu && <Actions $showActions={$showActions}>{menu}</Actions>}
{innerContent}
</Link>
);
}
@@ -273,6 +300,8 @@ const Link = styled(NavLink)<{
font-size: 16px;
cursor: var(--pointer);
overflow: hidden;
border: 0;
width: 100%;
${undraggableOnDesktop()}
${(props) =>
@@ -352,6 +381,8 @@ const Label = styled.div<{ $ellipsis: boolean }>`
line-height: 24px;
margin-left: 2px;
min-width: 0;
text-align: left;
${(props) => props.$ellipsis && ellipsis()}
* {
+5 -1
View File
@@ -367,7 +367,11 @@ export default function FindAndReplace({
return (
<Popover open={localOpen} onOpenChange={setLocalOpen}>
<PopoverTrigger>
<span style={style} />
<button
type="button"
aria-label={t("Find and replace")}
style={{ ...style, background: "none", border: 0, padding: 0 }}
/>
</PopoverTrigger>
<PopoverContent
aria-label={t("Find and replace")}
+6 -1
View File
@@ -72,7 +72,12 @@ function TableOfContentsMenu() {
return (
<DropdownMenu action={rootAction} ariaLabel={t("Table of contents")}>
<Button icon={<TableOfContentsIcon />} borderOnHover neutral />
<Button
icon={<TableOfContentsIcon />}
aria-label={t("Table of contents")}
borderOnHover
neutral
/>
</DropdownMenu>
);
}
@@ -275,7 +275,9 @@ const DocumentTitle = React.forwardRef(function DocumentTitle_(
</React.Suspense>
</IconTitleWrapper>
) : icon ? (
<IconTitleWrapper dir={dir}>{fallbackIcon}</IconTitleWrapper>
<IconTitleWrapper dir={dir} aria-hidden>
{fallbackIcon}
</IconTitleWrapper>
) : null}
</Title>
);
@@ -77,30 +77,32 @@ function ReferenceListItem({
const initial = title.charAt(0).toUpperCase();
return (
<DocumentLink
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
to={{
pathname: shareId
? sharedModelPath(shareId, document.url)
: document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: {
title: document.title,
sidebarContext,
},
}}
{...rest}
>
<Content gap={4} dir="auto">
{icon ? (
<Icon value={icon} color={color ?? undefined} initial={initial} />
) : (
<DocumentIcon />
)}
<Title>{isEmoji ? title.replace(icon!, "") : title}</Title>
</Content>
</DocumentLink>
<li>
<DocumentLink
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
to={{
pathname: shareId
? sharedModelPath(shareId, document.url)
: document.url,
hash: anchor ? `d-${anchor}` : undefined,
state: {
title: document.title,
sidebarContext,
},
}}
{...rest}
>
<Content gap={4} dir="auto">
{icon ? (
<Icon value={icon} color={color ?? undefined} initial={initial} />
) : (
<DocumentIcon />
)}
<Title>{isEmoji ? title.replace(icon!, "") : title}</Title>
</Content>
</DocumentLink>
</li>
);
}
@@ -171,12 +171,15 @@ const Content = styled.div`
position: relative;
`;
const List = styled.div<{ $active: boolean }>`
const List = styled.ul<{ $active: boolean }>`
visibility: ${({ $active }) => ($active ? "visible" : "hidden")};
position: absolute;
top: 0;
left: 0;
right: 0;
list-style: none;
margin: 0;
padding: 0;
`;
export default observer(References);
+1
View File
@@ -42,6 +42,7 @@ function SharedCollection({ collection }: Props) {
to={{
pathname: collectionPath(collection, "overview"),
}}
aria-label={t("Edit collection")}
neutral
>
{isMobile ? null : t("Edit")}
+2 -2
View File
@@ -130,7 +130,7 @@ async function start(_id: number, disconnect: () => void) {
}
this.body = `
<html>
<html lang="en">
<head>
<title>Redirecting…</title>
</head>
@@ -146,7 +146,7 @@ async function start(_id: number, disconnect: () => void) {
} else {
// Default GET method using meta refresh
this.body = `
<html>
<html lang="en">
<head>
<meta http-equiv="refresh" content="0;URL='${escape(url)}'" />
</head>
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<title>Error - //inject-status//</title>
<meta
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html lang="en">
<head>
<title>Error - //inject-status//</title>
<meta
+6 -1
View File
@@ -30,7 +30,12 @@ const Span = styled.span<{ $size: number }>`
`;
const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
<svg
width={size}
height={size}
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<text
x="50%"
y="55%"
+10 -1
View File
@@ -200,6 +200,7 @@ export default class Heading extends Node {
anchor.innerText = "#";
anchor.type = "button";
anchor.className = "heading-anchor";
anchor.setAttribute("aria-label", "Copy link to heading");
anchor.addEventListener("mousedown", (event) =>
this.handleCopyLink(event)
);
@@ -213,7 +214,15 @@ export default class Heading extends Node {
fold.className = `heading-fold ${
node.attrs.collapsed ? "collapsed" : ""
}`;
fold.addEventListener("mousedown", (event) =>
fold.setAttribute(
"aria-label",
node.attrs.collapsed ? "Expand section" : "Collapse section"
);
fold.setAttribute(
"aria-expanded",
(!node.attrs.collapsed).toString()
);
fold.addEventListener("click", (event) =>
this.handleFoldContent(event)
);