feat: A11y fixes (#2148)

This commit is contained in:
Philip Okugbe
2026-05-04 21:21:37 +01:00
committed by GitHub
parent fe18f22dc6
commit dbe6c2d6ba
62 changed files with 587 additions and 163 deletions
@@ -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>
@@ -172,6 +172,9 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
fontWeight: 500,
fontSize: rem(16),
}}
aria-label={t("Text color")}
aria-haspopup="dialog"
aria-expanded={isOpen}
>
A
</Button>
@@ -186,20 +189,32 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
{t("Text color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{TEXT_COLORS.map(({ name, color }, index) => (
{TEXT_COLORS.map(({ name, color }, index) => {
const applyTextColor = () => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow>
<Box
onClick={() => {
if (name === "Default") {
editor.commands.unsetColor();
} else {
editor
.chain()
.focus()
.setColor(color || "")
.run();
role="button"
tabIndex={0}
aria-label={t(name)}
aria-pressed={!!editorState[`text_${color}`]}
onClick={applyTextColor}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
applyTextColor();
}
setIsOpen(false);
}}
style={{
width: rem(28),
@@ -221,7 +236,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
A
</Box>
</Tooltip>
))}
);
})}
</SimpleGrid>
</Box>
@@ -230,23 +246,35 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
{t("Highlight color")}
</Text>
<SimpleGrid cols={5} spacing="xs">
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
const applyHighlight = () => {
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
}
setIsOpen(false);
};
return (
<Tooltip key={index} label={t(name)} withArrow>
<Box
onClick={() => {
if (name === "Default") {
editor.commands.unsetHighlight();
} else {
editor
.chain()
.focus()
.toggleMark("highlight", {
color: color || "",
colorName: name.toLowerCase() || "",
})
.run();
role="button"
tabIndex={0}
aria-label={t(name)}
aria-pressed={!!editorState[`highlight_${color}`]}
onClick={applyHighlight}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
applyHighlight();
}
setIsOpen(false);
}}
style={{
width: rem(28),
@@ -274,7 +302,8 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
)}
</Box>
</Tooltip>
))}
);
})}
</SimpleGrid>
</Box>
@@ -157,6 +157,9 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
aria-label={t("Turn into")}
aria-haspopup="menu"
aria-expanded={isOpen}
>
{t(activeItem?.name)}
</Button>
@@ -92,6 +92,9 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
radius="0"
rightSection={<IconChevronDown size={16} />}
onClick={() => setIsOpen(!isOpen)}
aria-label={t("Text align")}
aria-haspopup="menu"
aria-expanded={isOpen}
>
<activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
@@ -137,7 +137,13 @@ export default function DrawioView(props: NodeViewProps) {
return (
<NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Root
opened={opened}
onClose={handleClose}
fullScreen
closeOnEscape={false}
aria-label={t("Diagram editor")}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body pos="relative">
@@ -107,7 +107,17 @@ const EmojiList = ({
}, [selectedIndex]);
return items.length > 0 || isLoading ? (
<Paper id="emoji-command" p="0" shadow="md" withBorder>
<Paper
id="emoji-command"
p="0"
shadow="md"
withBorder
role="listbox"
aria-label="Emoji results"
aria-activedescendant={
items.length > 0 ? `emoji-command-option-${selectedIndex}` : undefined
}
>
{isLoading && <Loader m="xs" color="blue" type="dots" />}
{items.length > 0 && (
<ScrollArea.Autosize
@@ -120,6 +130,10 @@ const EmojiList = ({
{items.map((item, index: number) => (
<ActionIcon
data-item-index={index}
id={`emoji-command-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
aria-label={item.id}
variant="transparent"
key={item.id}
className={clsx(classes.menuBtn, {
@@ -102,6 +102,14 @@ export const LinkEditorPanel = ({
leftSection={<IconLink size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />}
classNames={{ input: classes.linkInput }}
placeholder={t("Paste link or search pages")}
aria-label={t("Paste link or search pages")}
role="combobox"
aria-expanded={showDropdown}
aria-controls="link-editor-results"
aria-autocomplete="list"
aria-activedescendant={
showDropdown ? `link-editor-option-${selectedIndex}` : undefined
}
value={state.url}
onChange={state.onChange}
onKeyDown={handleKeyDown}
@@ -125,10 +133,16 @@ export const LinkEditorPanel = ({
scrollbarSize={6}
mt={state.url.length > 0 ? 8 : 0}
styles={{ content: { minWidth: 0 } }}
id="link-editor-results"
role="listbox"
aria-label={t("Link suggestions")}
>
{showUrlItem && (
<UnstyledButton
data-item-index={0}
id="link-editor-option-0"
role="option"
aria-selected={selectedIndex === 0}
onClick={() => onSetLink(state.url, false)}
className={clsx(classes.searchItem, {
[classes.selectedSearchItem]: selectedIndex === 0,
@@ -156,6 +170,9 @@ export const LinkEditorPanel = ({
return (
<UnstyledButton
data-item-index={itemIndex}
id={`link-editor-option-${itemIndex}`}
role="option"
aria-selected={itemIndex === selectedIndex}
key={page.id || index}
onClick={() => selectPage(page)}
className={clsx(classes.searchItem, {
@@ -287,7 +287,16 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
);
return (
<Paper id="mention" shadow="md" withBorder radius="md" py={6}>
<Paper
id="mention"
shadow="md"
withBorder
radius="md"
py={6}
role="listbox"
aria-label={t("Mention suggestions")}
aria-activedescendant={`mention-option-${selectedIndex}`}
>
<ScrollArea.Autosize
viewportRef={viewportRef}
mah={350}
@@ -301,7 +310,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
if (item.entityType === "header") {
const isFirst = index === 0;
return (
<div key={`${item.label}-${index}`}>
<div key={`${item.label}-${index}`} role="presentation">
{!isFirst && <Divider my={6} />}
<Text
c="dimmed"
@@ -322,6 +331,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<UnstyledButton
data-item-index={index}
key={index}
id={`mention-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
@@ -348,6 +360,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<UnstyledButton
data-item-index={index}
key={index}
id={`mention-option-${index}`}
role="option"
aria-selected={index === selectedIndex}
onClick={() => selectItem(index)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
@@ -358,7 +373,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
<ActionIcon
variant="subtle"
component="div"
aria-label={item.label}
aria-hidden="true"
color="gray"
size="sm"
>
@@ -390,6 +405,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
{(hasUsers || hasPages) && <Divider my={6} />}
<UnstyledButton
data-item-index={renderItems.indexOf(createPageItemData)}
id={`mention-option-${renderItems.indexOf(createPageItemData)}`}
role="option"
aria-selected={
renderItems.indexOf(createPageItemData) === selectedIndex
}
onClick={() =>
selectItem(renderItems.indexOf(createPageItemData))
}
@@ -405,6 +425,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
component="div"
color="gray"
size="sm"
aria-hidden="true"
>
<IconPlus size={16} stroke={1.5} />
</ActionIcon>
@@ -92,7 +92,20 @@ export default function PdfView(props: NodeViewProps) {
if (hasError) {
return (
<NodeViewWrapper data-drag-handle>
<div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}>
<div
data-pdf-error
className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })}
onClick={handleSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelect();
}
}}
role="button"
tabIndex={0}
aria-label={t("Failed to load PDF")}
>
<IconFileTypePdf size={32} stroke={1.5} />
<Text size="sm" c="dimmed">
{t("Failed to load PDF")}
@@ -187,12 +187,14 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
position={{ top: 90, right: 50 }}
withBorder
transitionProps={{ transition: "slide-down" }}
aria-label={t("Find and replace")}
>
<Stack gap="xs">
<Flex align="center" gap="xs">
<Input
ref={inputRef}
placeholder={t("Find")}
aria-label={t("Find")}
leftSection={<IconSearch size={16} />}
rightSection={
<Text size="xs" ta="right">
@@ -217,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}
@@ -225,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}
@@ -237,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%" }}
@@ -250,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%" }}
@@ -259,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>
@@ -269,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"
@@ -86,7 +86,15 @@ const CommandList = ({
}, [selectedIndex]);
return flatItems.length > 0 ? (
<Paper id="slash-command" shadow="md" p="xs" withBorder>
<Paper
id="slash-command"
shadow="md"
p="xs"
withBorder
role="listbox"
aria-label={t("Slash commands")}
aria-activedescendant={`slash-command-option-${selectedIndex}`}
>
<ScrollArea
viewportRef={viewportRef}
h={350}
@@ -94,22 +102,30 @@ const CommandList = ({
scrollbarSize={8}
overscrollBehavior="contain"
>
{Object.entries(items).map(([category, categoryItems]) => (
<div key={category}>
{(() => {
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}
onClick={() => selectItem(index)}
data-item-index={itemIndex}
key={itemIndex}
id={`slash-command-option-${itemIndex}`}
role="option"
aria-selected={itemIndex === selectedIndex}
onClick={() => selectItem(itemIndex)}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex,
[classes.selectedItem]: itemIndex === selectedIndex,
})}
>
<Group>
<ActionIcon variant="default" component="div">
<ActionIcon variant="default" component="div" aria-hidden="true">
<item.icon size={18} />
</ActionIcon>
@@ -124,9 +140,11 @@ const CommandList = ({
</div>
</Group>
</UnstyledButton>
))}
);
})}
</div>
))}
));
})()}
</ScrollArea>
</Paper>
) : null;
@@ -92,8 +92,17 @@ export default function StatusView(props: NodeViewProps) {
colorClassMap[color],
)}
onClick={() => isEditable && setOpened(true)}
onKeyDown={(e) => {
if (isEditable && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setOpened(true);
}
}}
role="button"
tabIndex={0}
aria-label={text || "SET STATUS"}
aria-haspopup="dialog"
aria-expanded={opened}
>
{text || "SET STATUS"}
</span>
@@ -127,6 +136,16 @@ export default function StatusView(props: NodeViewProps) {
)}
style={{ backgroundColor: bg }}
onClick={() => handleColorChange(name)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleColorChange(name);
}
}}
role="button"
tabIndex={0}
aria-label={name}
aria-pressed={color === name}
>
{color === name && <IconCheck size={14} />}
</Box>
@@ -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>