Przeglądaj źródła

feat(editor): add ARIA roles, labels, live region, and accessibility CSS

- Block editor DOM: role="textbox", aria-multiline="true",
  dynamic aria-label from content type, data-block-id
- Insertion zone: role="button", aria-label="Insert new block"
- Toolbar: aria-pressed on all toggle buttons
- Suggestion menu: role=listbox, role=option, aria-selected
- Live region (aria-live="polite") announcing block operations
- respects prefers-reduced-motion and forced-colors
Zander Hawke 2 dni temu
rodzic
commit
97154a1b58

+ 32 - 0
packages/editor/src/assets/style.css

@@ -543,3 +543,35 @@
 .ProseMirror-focused .ProseMirror-gapcursor {
   display: block;
 }
+
+/* ── Accessibility ────────────────────────────────────────────────── */
+
+@media (prefers-reduced-motion: reduce) {
+  .insertion-zone,
+  .md-page-ref,
+  .md-block-ref,
+  .md-tag,
+  .ProseMirror-gapcursor {
+    transition: none !important;
+    animation: none !important;
+  }
+}
+
+@media (forced-colors: active) {
+  .md-page-ref,
+  .md-block-ref,
+  .md-tag {
+    border: 1px solid ButtonText;
+    background: ButtonFace;
+    color: ButtonText;
+  }
+
+  .md-ref-unresolved {
+    border-style: dashed;
+  }
+
+  .md-code,
+  .md-math-error {
+    border: 1px solid ButtonText;
+  }
+}

+ 18 - 0
packages/editor/src/components/Editor.vue

@@ -91,6 +91,15 @@ let internalUpdatePending = false
 
 const blocks = ref<EditorBlockData[]>([])
 
+const liveAnnouncement = ref("")
+
+let announceTimer: ReturnType<typeof setTimeout> | null = null
+function announce(msg: string) {
+  liveAnnouncement.value = msg
+  if (announceTimer) clearTimeout(announceTimer)
+  announceTimer = setTimeout(() => { liveAnnouncement.value = "" }, 2000)
+}
+
 const registry = useFocusRegistry()
 
 const log = createLogger("Editor", props.debug)
@@ -445,6 +454,7 @@ function onSplit(index: number, before: string, after: string) {
     generateBlockId,
   )
   if (!result.newBlock) return
+  announce("Block split")
   blocks.value = result.blocks
   onBlockContentChange()
 
@@ -466,6 +476,7 @@ function onSplit(index: number, before: string, after: string) {
 function onMergePrevious(index: number) {
   const result = opMergePrevious(blocks.value, index)
   if (result.blocks.length >= blocks.value.length) return
+  announce("Merged with previous block")
 
   const current = blocks.value[index]
   const prev = blocks.value[index - 1]
@@ -508,6 +519,7 @@ function onDeleteIfEmpty(index: number) {
   if (!result.nextId || result.blocks.length >= blocks.value.length) return
   const removedBlock = blocks.value[index]
   if (!removedBlock) return
+  announce("Block deleted")
 
   blocks.value = result.blocks
   onBlockContentChange()
@@ -672,6 +684,7 @@ function onZoneActivate(index: number, content: string) {
     canRedo.value = history.canRedo
     const lastInsertedId = insertedId
     if (lastInsertedId) {
+      announce("Inserted block")
       nextTick(() => registry.focus(lastInsertedId, "end"))
     }
   } else {
@@ -723,6 +736,11 @@ defineExpose({
 
 <template>
   <div class="editor-shell flex flex-col gap-4" :style="themeCSSVars">
+    <div
+      aria-live="polite"
+      aria-atomic="true"
+      class="sr-only"
+    >{{ liveAnnouncement }}</div>
     <div v-if="$slots.toolbar" class="editor-toolbar-wrapper">
       <slot
         name="toolbar"

+ 26 - 0
packages/editor/src/components/EditorBlock.vue

@@ -10,6 +10,7 @@ import {
 } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import {
+  computed,
   onMounted,
   onUnmounted,
   nextTick,
@@ -80,6 +81,21 @@ import { getRegisteredNodeViews } from "@/lib/node-view-registry"
 
 const content = defineModel<string>("content")
 
+const blockAriaLabel = computed(() => {
+  const text = content.value ?? ""
+  const firstLine = text.split("\n")[0]?.trim() ?? ""
+  if (firstLine.startsWith("```")) return "Code block"
+  if (firstLine.startsWith("$$")) return "Math block"
+  if (firstLine.startsWith("> [") && firstLine.endsWith("]")) return "Callout"
+  if (firstLine.startsWith(">")) return "Blockquote"
+  if (firstLine.startsWith("|")) return "Table"
+  if (/^#{1,6}\s/.test(firstLine)) return `Heading ${firstLine.match(/^#+/)?.[0]?.length ?? 0}`
+  if (/^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELLED)\b/.test(firstLine)) return "Task"
+  if (/^\d+[.)]\s/.test(firstLine)) return "Ordered list"
+  if (/^[-*+]\s/.test(firstLine)) return "Unordered list"
+  return "Paragraph"
+})
+
 const props = defineProps<{
   id?: string
   focused?: boolean
@@ -580,6 +596,11 @@ onMounted(() => {
   try {
     view = new EditorView(mountEl, {
       state: editorState,
+      attributes: {
+        role: "textbox",
+        "aria-multiline": "true",
+        "data-block-id": props.id ?? "",
+      },
       nodeViews: {
         code_block: (node, view, getPos) =>
           new CodeBlockView(
@@ -771,6 +792,11 @@ watchDebounced(
   { debounce: 50 },
 )
 
+watch(blockAriaLabel, (label) => {
+  if (!view) return
+  view.dom.setAttribute("aria-label", label)
+})
+
 defineExpose({
   get view() {
     return view

+ 2 - 0
packages/editor/src/components/EditorInsertionZone.vue

@@ -153,6 +153,8 @@ defineExpose({
 <template>
   <div
     ref="rootEl"
+    role="button"
+    aria-label="Insert new block"
     class="insertion-zone relative outline-none cursor-pointer transition-all duration-150"
     :class="focused ? 'h-10' : 'h-1'"
     @keydown="onKeydown"

+ 5 - 0
packages/editor/src/components/EditorSuggestionMenu.vue

@@ -135,6 +135,8 @@ defineExpose({ forwardKey })
 <template>
   <div
     v-if="active"
+    role="listbox"
+    aria-label="Suggestion menu"
     data-command-palette
     class="flex flex-col absolute z-50 w-80 shadow-lg rounded-lg overflow-hidden ring ring-default bg-(--ui-bg)"
     :style="{ top: `${props.position.y + 24}px`, left: `${props.position.x}px` }"
@@ -142,6 +144,7 @@ defineExpose({ forwardKey })
         <template v-for="(group, gIdx) in filteredGroups" :key="group.id">
           <div
             v-if="group.label && group.items?.length"
+            role="presentation"
             class="px-3 py-1.5 text-xs font-semibold text-(--ui-text-muted) uppercase tracking-wider select-none"
           >
             {{ group.label }}
@@ -150,6 +153,8 @@ defineExpose({ forwardKey })
           <div
             v-for="(item, iIdx) in group.items"
             :key="`${group.id}-${iIdx}`"
+            :role="'option'"
+            :aria-selected="getGlobalIndex(gIdx, iIdx) === activeIndex ? 'true' : 'false'"
             :data-highlighted="getGlobalIndex(gIdx, iIdx) === activeIndex ? '' : undefined"
             :class="[
               'flex items-center gap-2.5 px-3 py-2 cursor-pointer text-sm transition-colors',

+ 20 - 0
packages/editor/src/components/EditorToolbar.vue

@@ -174,6 +174,7 @@ function cancelPrompt() {
         size="sm"
         :active="currentTask !== null"
         :disabled="!handlers"
+        :aria-pressed="currentTask !== null ? 'true' : 'false'"
         @click="handlers?.toggleTask()"
       />
     </div>
@@ -192,6 +193,7 @@ function cancelPrompt() {
           size="sm"
           :active="active.bold"
           :disabled="!handlers"
+          :aria-pressed="active.bold ? 'true' : 'false'"
           @click="handlers?.toggleBold()"
         />
       </UTooltip>
@@ -205,6 +207,7 @@ function cancelPrompt() {
           size="sm"
           :active="active.italic"
           :disabled="!handlers"
+          :aria-pressed="active.italic ? 'true' : 'false'"
           @click="handlers?.toggleItalic()"
         />
       </UTooltip>
@@ -218,6 +221,7 @@ function cancelPrompt() {
           size="sm"
           :active="active.code"
           :disabled="!handlers"
+          :aria-pressed="active.code ? 'true' : 'false'"
           @click="handlers?.toggleCode()"
         />
       </UTooltip>
@@ -231,6 +235,7 @@ function cancelPrompt() {
           size="sm"
           :active="active.strikethrough"
           :disabled="!handlers"
+          :aria-pressed="active.strikethrough ? 'true' : 'false'"
           @click="handlers?.toggleStrikethrough()"
         />
       </UTooltip>
@@ -244,9 +249,24 @@ function cancelPrompt() {
           size="sm"
           :active="active.highlight"
           :disabled="!handlers"
+          :aria-pressed="active.highlight ? 'true' : 'false'"
           @click="handlers?.toggleHighlight()"
         />
       </UTooltip>
+      <UTooltip text="Inline math (⌘M)" :disabled="!handlers">
+        <UButton
+          icon="i-lucide-sigma"
+          color="neutral"
+          active-color="primary"
+          variant="ghost"
+          active-variant="soft"
+          size="sm"
+          :active="active.inlineMath"
+          :disabled="!handlers"
+          :aria-pressed="active.inlineMath ? 'true' : 'false'"
+          @click="handlers?.insertInlineMath()"
+        />
+      </UTooltip>
     </div>
 
     <USeparator orientation="vertical" class="w-px self-stretch bg-border" />