mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
accessibility
This commit is contained in:
@@ -899,5 +899,11 @@
|
||||
"Link suggestions": "Link suggestions",
|
||||
"Diagram editor": "Diagram editor",
|
||||
"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 React, { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
@@ -23,11 +24,12 @@ export default function GlobalAppShell({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useTrialEndAction();
|
||||
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
||||
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
||||
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
||||
const [{ isAsideOpen }] = useAtom(asideStateAtom);
|
||||
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
|
||||
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef(null);
|
||||
@@ -79,6 +81,10 @@ export default function GlobalAppShell({
|
||||
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute && !isAiRoute;
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href="#main-content" className={classes.skipLink}>
|
||||
{t("Skip to main content")}
|
||||
</a>
|
||||
<AppShell
|
||||
header={{ height: 45 }}
|
||||
navbar={{
|
||||
@@ -105,6 +111,15 @@ export default function GlobalAppShell({
|
||||
className={classes.navbar}
|
||||
withBorder={false}
|
||||
ref={sidebarRef}
|
||||
aria-label={
|
||||
isSpaceRoute
|
||||
? t("Space navigation")
|
||||
: isSettingsRoute
|
||||
? t("Settings navigation")
|
||||
: isAiRoute
|
||||
? t("AI navigation")
|
||||
: t("Main navigation")
|
||||
}
|
||||
>
|
||||
{isSpaceRoute && (
|
||||
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
||||
@@ -114,7 +129,7 @@ export default function GlobalAppShell({
|
||||
{isAiRoute && <AiChatSidebar />}
|
||||
{showGlobalSidebar && <GlobalSidebar />}
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main>
|
||||
<AppShell.Main id="main-content">
|
||||
{isSettingsRoute ? (
|
||||
<Container size={900}>{children}</Container>
|
||||
) : (
|
||||
@@ -123,10 +138,24 @@ export default function GlobalAppShell({
|
||||
</AppShell.Main>
|
||||
|
||||
{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 />
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollArea, Text, Divider, Modal } from "@mantine/core";
|
||||
import { ScrollArea, Text, Divider, Modal, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconHome,
|
||||
IconClock,
|
||||
@@ -119,17 +119,13 @@ export default function GlobalSidebar() {
|
||||
</ScrollArea>
|
||||
|
||||
<div className={classes.bottomSection}>
|
||||
<a
|
||||
<UnstyledButton
|
||||
className={classes.link}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openInvite();
|
||||
}}
|
||||
href="#"
|
||||
onClick={openInvite}
|
||||
>
|
||||
<IconUserPlus className={classes.linkIcon} stroke={2} />
|
||||
<span>{t("Invite People")}</span>
|
||||
</a>
|
||||
</UnstyledButton>
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={active.startsWith("/settings") || undefined}
|
||||
|
||||
@@ -226,32 +226,6 @@ export default function SettingsSidebar() {
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
@@ -261,12 +235,41 @@ export default function SettingsSidebar() {
|
||||
position="right"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
@@ -284,7 +287,7 @@ export default function SettingsSidebar() {
|
||||
}}
|
||||
variant="transparent"
|
||||
c="gray"
|
||||
aria-label="Back"
|
||||
aria-label={t("Back")}
|
||||
>
|
||||
<IconArrowLeft stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -36,6 +36,7 @@ export default function AudioView(props: NodeViewProps) {
|
||||
preload="metadata"
|
||||
controls
|
||||
src={safeSrc}
|
||||
aria-label={placeholder?.name || t("Audio")}
|
||||
/>
|
||||
)}
|
||||
{!safeSrc && previewSrc && (
|
||||
@@ -45,6 +46,7 @@ export default function AudioView(props: NodeViewProps) {
|
||||
preload="metadata"
|
||||
controls
|
||||
src={previewSrc}
|
||||
aria-label={placeholder?.name || t("Audio")}
|
||||
/>
|
||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||
</Group>
|
||||
@@ -60,7 +62,7 @@ export default function AudioView(props: NodeViewProps) {
|
||||
</Group>
|
||||
)}
|
||||
{!safeSrc && !previewSrc && !placeholder && (
|
||||
<audio className={classes.audio} controls />
|
||||
<audio className={classes.audio} controls aria-label={t("Audio")} />
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
|
||||
+24
-3
@@ -194,6 +194,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("Find")}
|
||||
aria-label={t("Find")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={
|
||||
<Text size="xs" ta="right">
|
||||
@@ -218,7 +219,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
|
||||
<ActionIcon.Group>
|
||||
<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
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
@@ -226,7 +232,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<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
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
@@ -238,6 +249,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
variant="subtle"
|
||||
color={caseSensitive.color}
|
||||
onClick={() => caseSensitiveToggle()}
|
||||
aria-label={t("Match case (Alt+C)")}
|
||||
aria-pressed={caseSensitive.isCaseSensitive}
|
||||
>
|
||||
<IconLetterCase
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
@@ -251,6 +264,8 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
variant="subtle"
|
||||
color={replaceButton.color}
|
||||
onClick={() => replaceButtonToggle()}
|
||||
aria-label={t("Replace")}
|
||||
aria-pressed={replaceButton.isReplaceShow}
|
||||
>
|
||||
<IconReplace
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
@@ -260,7 +275,12 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
</Tooltip>
|
||||
)}
|
||||
<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} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -270,6 +290,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
<Flex align="center" gap="xs">
|
||||
<Input
|
||||
placeholder={t("Replace")}
|
||||
aria-label={t("Replace")}
|
||||
leftSection={<IconReplace size={16} />}
|
||||
rightSection={<div></div>}
|
||||
rightSectionPointerEvents="all"
|
||||
|
||||
@@ -102,21 +102,26 @@ const CommandList = ({
|
||||
scrollbarSize={8}
|
||||
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}>
|
||||
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
|
||||
{category}
|
||||
</Text>
|
||||
{categoryItems.map((item: SlashMenuItemType, index: number) => (
|
||||
{categoryItems.map((item: SlashMenuItemType) => {
|
||||
flatIndex += 1;
|
||||
const itemIndex = flatIndex;
|
||||
return (
|
||||
<UnstyledButton
|
||||
data-item-index={index}
|
||||
key={index}
|
||||
id={`slash-command-option-${index}`}
|
||||
data-item-index={itemIndex}
|
||||
key={itemIndex}
|
||||
id={`slash-command-option-${itemIndex}`}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
onClick={() => selectItem(index)}
|
||||
aria-selected={itemIndex === selectedIndex}
|
||||
onClick={() => selectItem(itemIndex)}
|
||||
className={clsx(classes.menuBtn, {
|
||||
[classes.selectedItem]: index === selectedIndex,
|
||||
[classes.selectedItem]: itemIndex === selectedIndex,
|
||||
})}
|
||||
>
|
||||
<Group>
|
||||
@@ -135,9 +140,11 @@ const CommandList = ({
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
));
|
||||
})()}
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
preload="metadata"
|
||||
controls
|
||||
src={getFileUrl(src)}
|
||||
aria-label={placeholder?.name || t("Video")}
|
||||
/>
|
||||
)}
|
||||
{!src && previewSrc && (
|
||||
@@ -56,6 +57,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
preload="metadata"
|
||||
controls
|
||||
src={previewSrc}
|
||||
aria-label={placeholder?.name || t("Video")}
|
||||
/>
|
||||
<Loader size={20} pos="absolute" top={6} right={6} />
|
||||
</Group>
|
||||
@@ -71,7 +73,7 @@ export default function VideoView(props: NodeViewProps) {
|
||||
</Group>
|
||||
)}
|
||||
{!src && !previewSrc && !placeholder && (
|
||||
<video className={classes.video} controls />
|
||||
<video className={classes.video} controls aria-label={t("Video")} />
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function Breadcrumb() {
|
||||
));
|
||||
|
||||
const renderAnchor = useCallback(
|
||||
(node: SpaceTreeNode) => (
|
||||
(node: SpaceTreeNode, isCurrent = false) => (
|
||||
<Tooltip label={node.name} key={node.id}>
|
||||
<Anchor
|
||||
component={Link}
|
||||
@@ -91,6 +91,7 @@ export default function Breadcrumb() {
|
||||
fz="sm"
|
||||
key={node.id}
|
||||
className={classes.truncatedText}
|
||||
aria-current={isCurrent ? "page" : undefined}
|
||||
>
|
||||
{getTitle(node.name, node.icon)}
|
||||
</Anchor>
|
||||
@@ -130,11 +131,13 @@ export default function Breadcrumb() {
|
||||
</Popover.Dropdown>
|
||||
</Popover>,
|
||||
//renderAnchor(secondLastNode),
|
||||
renderAnchor(lastNode),
|
||||
renderAnchor(lastNode, true),
|
||||
];
|
||||
}
|
||||
|
||||
return breadcrumbNodes.map(renderAnchor);
|
||||
return breadcrumbNodes.map((node, i) =>
|
||||
renderAnchor(node, i === breadcrumbNodes.length - 1),
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={classes.breadcrumbDiv}>
|
||||
<nav aria-label={t("Breadcrumb")} className={classes.breadcrumbDiv}>
|
||||
{breadcrumbNodes && (
|
||||
<Breadcrumbs className={classes.breadcrumbs}>
|
||||
{isMobile ? getMobileBreadcrumbItems() : getBreadcrumbItems()}
|
||||
</Breadcrumbs>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user