accessibility

This commit is contained in:
Philipinho
2026-04-20 19:45:55 +01:00
parent fadeeaa59d
commit 6fdab5fe70
10 changed files with 152 additions and 63 deletions
@@ -899,5 +899,11 @@
"Link suggestions": "Link suggestions", "Link suggestions": "Link suggestions",
"Diagram editor": "Diagram editor", "Diagram editor": "Diagram editor",
"Add comment": "Add comment", "Add comment": "Add comment",
"Find and replace": "Find and replace" "Find and replace": "Find and replace",
"Main navigation": "Main navigation",
"Space navigation": "Space navigation",
"Settings navigation": "Settings navigation",
"AI navigation": "AI navigation",
"Breadcrumb": "Breadcrumb",
"Skip to main content": "Skip to main content"
} }
@@ -28,4 +28,22 @@
} }
} }
.skipLink {
position: fixed;
left: 8px;
top: 8px;
padding: 8px 12px;
background: var(--mantine-color-blue-6);
color: #fff;
border-radius: 4px;
text-decoration: none;
z-index: 1000;
transform: translateY(-150%);
&:focus {
transform: translateY(0);
outline: 2px solid var(--mantine-color-blue-3);
}
}
@@ -1,6 +1,7 @@
import { AppShell, Container } from "@mantine/core"; import { AppShell, Container } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx"; import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
@@ -23,11 +24,12 @@ export default function GlobalAppShell({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { t } = useTranslation();
useTrialEndAction(); useTrialEndAction();
const [mobileOpened] = useAtom(mobileSidebarAtom); const [mobileOpened] = useAtom(mobileSidebarAtom);
const toggleMobile = useToggleSidebar(mobileSidebarAtom); const toggleMobile = useToggleSidebar(mobileSidebarAtom);
const [desktopOpened] = useAtom(desktopSidebarAtom); const [desktopOpened] = useAtom(desktopSidebarAtom);
const [{ isAsideOpen }] = useAtom(asideStateAtom); const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom); const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef(null); const sidebarRef = useRef(null);
@@ -79,7 +81,11 @@ export default function GlobalAppShell({
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute; const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
return ( return (
<AppShell <>
<a href="#main-content" className={classes.skipLink}>
{t("Skip to main content")}
</a>
<AppShell
header={{ height: 45 }} header={{ height: 45 }}
navbar={{ navbar={{
width: isSpaceRoute ? sidebarWidth : 300, width: isSpaceRoute ? sidebarWidth : 300,
@@ -105,6 +111,15 @@ export default function GlobalAppShell({
className={classes.navbar} className={classes.navbar}
withBorder={false} withBorder={false}
ref={sidebarRef} ref={sidebarRef}
aria-label={
isSpaceRoute
? t("Space navigation")
: isSettingsRoute
? t("Settings navigation")
: isAiRoute
? t("AI navigation")
: t("Main navigation")
}
> >
{isSpaceRoute && ( {isSpaceRoute && (
<div className={classes.resizeHandle} onMouseDown={startResizing} /> <div className={classes.resizeHandle} onMouseDown={startResizing} />
@@ -114,7 +129,7 @@ export default function GlobalAppShell({
{isAiRoute && <AiChatSidebar />} {isAiRoute && <AiChatSidebar />}
{showGlobalSidebar && <GlobalSidebar />} {showGlobalSidebar && <GlobalSidebar />}
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main id="main-content">
{isSettingsRoute ? ( {isSettingsRoute ? (
<Container size={900}>{children}</Container> <Container size={900}>{children}</Container>
) : ( ) : (
@@ -123,10 +138,24 @@ export default function GlobalAppShell({
</AppShell.Main> </AppShell.Main>
{isPageRoute && ( {isPageRoute && (
<AppShell.Aside className={classes.aside} p="md" withBorder={false}> <AppShell.Aside
className={classes.aside}
p="md"
withBorder={false}
aria-label={
asideTab === "comments"
? t("Comments")
: asideTab === "toc"
? t("Table of contents")
: asideTab === "chat"
? t("AI Chat")
: undefined
}
>
<Aside /> <Aside />
</AppShell.Aside> </AppShell.Aside>
)} )}
</AppShell> </AppShell>
</>
); );
} }
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ScrollArea, Text, Divider, Modal } from "@mantine/core"; import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
import { import {
IconHome, IconHome,
IconClock, IconClock,
@@ -119,17 +119,13 @@ export default function GlobalSidebar() {
</ScrollArea> </ScrollArea>
<div className={classes.bottomSection}> <div className={classes.bottomSection}>
<a <UnstyledButton
className={classes.link} className={classes.link}
onClick={(e) => { onClick={openInvite}
e.preventDefault();
openInvite();
}}
href="#"
> >
<IconUserPlus className={classes.linkIcon} stroke={2} /> <IconUserPlus className={classes.linkIcon} stroke={2} />
<span>{t("Invite People")}</span> <span>{t("Invite People")}</span>
</a> </UnstyledButton>
<Link <Link
className={classes.link} className={classes.link}
data-active={active.startsWith("/settings") || undefined} data-active={active.startsWith("/settings") || undefined}
@@ -226,32 +226,6 @@ export default function SettingsSidebar() {
} }
const isDisabled = isItemDisabled(item); const isDisabled = isItemDisabled(item);
const linkElement = (
<Link
onMouseEnter={!isDisabled ? prefetchHandler : undefined}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
data-disabled={isDisabled || undefined}
key={item.label}
to={isDisabled ? "#" : item.path}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
return;
}
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
style={{
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? "not-allowed" : "pointer",
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
if (isDisabled) { if (isDisabled) {
return ( return (
@@ -261,12 +235,41 @@ export default function SettingsSidebar() {
position="right" position="right"
withArrow withArrow
> >
{linkElement} <span
className={classes.link}
data-disabled
role="link"
aria-disabled="true"
tabIndex={0}
style={{
opacity: 0.5,
cursor: "not-allowed",
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</span>
</Tooltip> </Tooltip>
); );
} }
return linkElement; return (
<Link
onMouseEnter={prefetchHandler}
className={classes.link}
data-active={active.startsWith(item.path) || undefined}
key={item.label}
to={item.path}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
}
}}
>
<item.icon className={classes.linkIcon} stroke={2} />
<span>{t(item.label)}</span>
</Link>
);
})} })}
</div> </div>
); );
@@ -284,7 +287,7 @@ export default function SettingsSidebar() {
}} }}
variant="transparent" variant="transparent"
c="gray" c="gray"
aria-label="Back" aria-label={t("Back")}
> >
<IconArrowLeft stroke={2} /> <IconArrowLeft stroke={2} />
</ActionIcon> </ActionIcon>
@@ -36,6 +36,7 @@ export default function AudioView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={safeSrc} src={safeSrc}
aria-label={placeholder?.name || t("Audio")}
/> />
)} )}
{!safeSrc && previewSrc && ( {!safeSrc && previewSrc && (
@@ -45,6 +46,7 @@ export default function AudioView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={previewSrc} src={previewSrc}
aria-label={placeholder?.name || t("Audio")}
/> />
<Loader size={20} pos="absolute" top={6} right={6} /> <Loader size={20} pos="absolute" top={6} right={6} />
</Group> </Group>
@@ -60,7 +62,7 @@ export default function AudioView(props: NodeViewProps) {
</Group> </Group>
)} )}
{!safeSrc && !previewSrc && !placeholder && ( {!safeSrc && !previewSrc && !placeholder && (
<audio className={classes.audio} controls /> <audio className={classes.audio} controls aria-label={t("Audio")} />
)} )}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
@@ -194,6 +194,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
<Input <Input
ref={inputRef} ref={inputRef}
placeholder={t("Find")} placeholder={t("Find")}
aria-label={t("Find")}
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
rightSection={ rightSection={
<Text size="xs" ta="right"> <Text size="xs" ta="right">
@@ -218,7 +219,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
<ActionIcon.Group> <ActionIcon.Group>
<Tooltip label={t("Previous match (Shift+Enter)")}> <Tooltip label={t("Previous match (Shift+Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={previous}> <ActionIcon
variant="subtle"
color="gray"
onClick={previous}
aria-label={t("Previous match (Shift+Enter)")}
>
<IconArrowNarrowUp <IconArrowNarrowUp
style={{ width: "70%", height: "70%" }} style={{ width: "70%", height: "70%" }}
stroke={1.5} stroke={1.5}
@@ -226,7 +232,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t("Next match (Enter)")}> <Tooltip label={t("Next match (Enter)")}>
<ActionIcon variant="subtle" color="gray" onClick={next}> <ActionIcon
variant="subtle"
color="gray"
onClick={next}
aria-label={t("Next match (Enter)")}
>
<IconArrowNarrowDown <IconArrowNarrowDown
style={{ width: "70%", height: "70%" }} style={{ width: "70%", height: "70%" }}
stroke={1.5} stroke={1.5}
@@ -238,6 +249,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
variant="subtle" variant="subtle"
color={caseSensitive.color} color={caseSensitive.color}
onClick={() => caseSensitiveToggle()} onClick={() => caseSensitiveToggle()}
aria-label={t("Match case (Alt+C)")}
aria-pressed={caseSensitive.isCaseSensitive}
> >
<IconLetterCase <IconLetterCase
style={{ width: "70%", height: "70%" }} style={{ width: "70%", height: "70%" }}
@@ -251,6 +264,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
variant="subtle" variant="subtle"
color={replaceButton.color} color={replaceButton.color}
onClick={() => replaceButtonToggle()} onClick={() => replaceButtonToggle()}
aria-label={t("Replace")}
aria-pressed={replaceButton.isReplaceShow}
> >
<IconReplace <IconReplace
style={{ width: "70%", height: "70%" }} style={{ width: "70%", height: "70%" }}
@@ -260,7 +275,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
</Tooltip> </Tooltip>
)} )}
<Tooltip label={t("Close (Escape)")}> <Tooltip label={t("Close (Escape)")}>
<ActionIcon variant="subtle" color="gray" onClick={closeDialog}> <ActionIcon
variant="subtle"
color="gray"
onClick={closeDialog}
aria-label={t("Close (Escape)")}
>
<IconX style={{ width: "70%", height: "70%" }} stroke={1.5} /> <IconX style={{ width: "70%", height: "70%" }} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -270,6 +290,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
<Flex align="center" gap="xs"> <Flex align="center" gap="xs">
<Input <Input
placeholder={t("Replace")} placeholder={t("Replace")}
aria-label={t("Replace")}
leftSection={<IconReplace size={16} />} leftSection={<IconReplace size={16} />}
rightSection={<div></div>} rightSection={<div></div>}
rightSectionPointerEvents="all" rightSectionPointerEvents="all"
@@ -102,21 +102,26 @@ const CommandList = ({
scrollbarSize={8} scrollbarSize={8}
overscrollBehavior="contain" overscrollBehavior="contain"
> >
{Object.entries(items).map(([category, categoryItems]) => ( {(() => {
let flatIndex = -1;
return Object.entries(items).map(([category, categoryItems]) => (
<div key={category} role="group" aria-label={category}> <div key={category} role="group" aria-label={category}>
<Text c="dimmed" mb={4} fw={500} tt="capitalize"> <Text c="dimmed" mb={4} fw={500} tt="capitalize">
{category} {category}
</Text> </Text>
{categoryItems.map((item: SlashMenuItemType, index: number) => ( {categoryItems.map((item: SlashMenuItemType) => {
flatIndex += 1;
const itemIndex = flatIndex;
return (
<UnstyledButton <UnstyledButton
data-item-index={index} data-item-index={itemIndex}
key={index} key={itemIndex}
id={`slash-command-option-${index}`} id={`slash-command-option-${itemIndex}`}
role="option" role="option"
aria-selected={index === selectedIndex} aria-selected={itemIndex === selectedIndex}
onClick={() => selectItem(index)} onClick={() => selectItem(itemIndex)}
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex, [classes.selectedItem]: itemIndex === selectedIndex,
})} })}
> >
<Group> <Group>
@@ -135,9 +140,11 @@ const CommandList = ({
</div> </div>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
))} );
})}
</div> </div>
))} ));
})()}
</ScrollArea> </ScrollArea>
</Paper> </Paper>
) : null; ) : null;
@@ -47,6 +47,7 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={getFileUrl(src)} src={getFileUrl(src)}
aria-label={placeholder?.name || t("Video")}
/> />
)} )}
{!src && previewSrc && ( {!src && previewSrc && (
@@ -56,6 +57,7 @@ export default function VideoView(props: NodeViewProps) {
preload="metadata" preload="metadata"
controls controls
src={previewSrc} src={previewSrc}
aria-label={placeholder?.name || t("Video")}
/> />
<Loader size={20} pos="absolute" top={6} right={6} /> <Loader size={20} pos="absolute" top={6} right={6} />
</Group> </Group>
@@ -71,7 +73,7 @@ export default function VideoView(props: NodeViewProps) {
</Group> </Group>
)} )}
{!src && !previewSrc && !placeholder && ( {!src && !previewSrc && !placeholder && (
<video className={classes.video} controls /> <video className={classes.video} controls aria-label={t("Video")} />
)} )}
</div> </div>
</NodeViewWrapper> </NodeViewWrapper>
@@ -82,7 +82,7 @@ export default function Breadcrumb() {
)); ));
const renderAnchor = useCallback( const renderAnchor = useCallback(
(node: SpaceTreeNode) => ( (node: SpaceTreeNode, isCurrent = false) => (
<Tooltip label={node.name} key={node.id}> <Tooltip label={node.name} key={node.id}>
<Anchor <Anchor
component={Link} component={Link}
@@ -91,6 +91,7 @@ export default function Breadcrumb() {
fz="sm" fz="sm"
key={node.id} key={node.id}
className={classes.truncatedText} className={classes.truncatedText}
aria-current={isCurrent ? "page" : undefined}
> >
{getTitle(node.name, node.icon)} {getTitle(node.name, node.icon)}
</Anchor> </Anchor>
@@ -130,11 +131,13 @@ export default function Breadcrumb() {
</Popover.Dropdown> </Popover.Dropdown>
</Popover>, </Popover>,
//renderAnchor(secondLastNode), //renderAnchor(secondLastNode),
renderAnchor(lastNode), renderAnchor(lastNode, true),
]; ];
} }
return breadcrumbNodes.map(renderAnchor); return breadcrumbNodes.map((node, i) =>
renderAnchor(node, i === breadcrumbNodes.length - 1),
);
}; };
const getMobileBreadcrumbItems = () => { const getMobileBreadcrumbItems = () => {
@@ -167,16 +170,18 @@ export default function Breadcrumb() {
]; ];
} }
return breadcrumbNodes.map(renderAnchor); return breadcrumbNodes.map((node, i) =>
renderAnchor(node, i === breadcrumbNodes.length - 1),
);
}; };
return ( return (
<div className={classes.breadcrumbDiv}> <nav aria-label={t("Breadcrumb")} className={classes.breadcrumbDiv}>
{breadcrumbNodes && ( {breadcrumbNodes && (
<Breadcrumbs className={classes.breadcrumbs}> <Breadcrumbs className={classes.breadcrumbs}>
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()} {isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
</Breadcrumbs> </Breadcrumbs>
)} )}
</div> </nav>
); );
} }