Skip to content

Commit 208d820

Browse files
authored
Merge pull request #560 from Harbour-Enterprises/feature/har-9796-1-accessibility-keyboard-navigation-focus-management
HAR-9796 - Accessibility keyboard navigation and focus management
2 parents 619937f + 6a0f9d8 commit 208d820

5 files changed

Lines changed: 139 additions & 19 deletions

File tree

packages/super-editor/src/components/SuperEditor.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,20 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } =
138138
};
139139
140140
const handleSuperEditorKeydown = (event) => {
141+
// cmd/ctrl + opt/alt + shift + M
142+
if ((event.metaKey || event.ctrlKey) && event.altKey && event.shiftKey) {
143+
if (event.code === 'KeyM') {
144+
const toolbar = document.querySelector('.superdoc-toolbar');
145+
if (toolbar) {
146+
toolbar.setAttribute('tabindex', '0');
147+
toolbar.focus();
148+
}
149+
}
150+
}
141151
emit('editor-keydown', { editor: editor.value });
142152
};
143153
154+
144155
const handleSuperEditorClick = (event) => {
145156
emit('editor-click', { editor: editor.value });
146157
let pmElement = editorElem.value?.querySelector('.ProseMirror');

packages/super-editor/src/components/toolbar/ButtonGroup.vue

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ const isButton = (item) => item.type === 'button';
5555
const isDropdown = (item) => item.type === 'dropdown';
5656
const isSeparator = (item) => item.type === 'separator';
5757
const isOverflow = (item) => item.type === 'overflow';
58-
const handleToolbarButtonClick = (item, argument = null) => {
58+
const handleToolbarButtonClick = (item, argument = null, switchFocusToEditor = true) => {
5959
currentItem.value = item;
6060
currentItem.value.expand = true;
6161
if (item.disabled.value) return;
62-
emit('command', { item, argument });
62+
emit('command', { item, argument, switchFocusToEditor });
6363
};
6464
6565
const handleToolbarButtonTextSubmit = (item, argument) => {
@@ -78,7 +78,7 @@ const selectedOption = ref(null);
7878
const handleSelect = (item, option) => {
7979
closeDropdowns();
8080
const value = item.dropdownValueKey.value ? option[item.dropdownValueKey.value] : option.label;
81-
emit('command', { item, argument: value, option });
81+
emit('command', { item, argument: value, option, switchFocusToEditor: true });
8282
selectedOption.value = option.key;
8383
};
8484
@@ -105,18 +105,110 @@ const getDropdownAttributes = (option, item) => {
105105
const handleClickOutside = (e) => {
106106
closeDropdowns();
107107
};
108+
109+
110+
const moveToNextButton = (e) => {
111+
const currentButton = e.target;
112+
const nextButton = e.target.closest('.toolbar-item-ctn').nextElementSibling;
113+
if (nextButton) {
114+
currentButton.setAttribute('tabindex', '-1');
115+
nextButton.setAttribute('tabindex', '0');
116+
nextButton.focus();
117+
}
118+
};
119+
120+
const moveToPreviousButton = (e) => {
121+
const currentButton = e.target;
122+
const previousButton = e.target.closest('.toolbar-item-ctn').previousElementSibling;
123+
if (previousButton) {
124+
currentButton.setAttribute('tabindex', '-1');
125+
previousButton.setAttribute('tabindex', '0');
126+
previousButton.focus();
127+
}
128+
};
129+
130+
const moveToNextButtonGroup = (e) => {
131+
const nextButtonGroup = e.target.closest('.button-group').nextElementSibling;
132+
if (nextButtonGroup) {
133+
nextButtonGroup.setAttribute('tabindex', '0');
134+
nextButtonGroup.focus();
135+
} else {
136+
// Move to the editor
137+
const editor = document.querySelector('.ProseMirror');
138+
if (editor) {
139+
editor.focus();
140+
}
141+
}
142+
};
143+
144+
const moveToPreviousButtonGroup = (e) => {
145+
const previousButtonGroup = e.target.closest('.button-group').previousElementSibling;
146+
if (previousButtonGroup) {
147+
previousButtonGroup.setAttribute('tabindex', '0');
148+
previousButtonGroup.focus();
149+
}
150+
};
151+
152+
// Implement keyboard navigation using Roving Tabindex
153+
// https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
154+
// Set tabindex to 0 for the current focused button
155+
// Set tabindex to -1 for all other buttons
156+
const handleKeyDown = (e, item) => {
157+
e.preventDefault();
158+
switch (e.key) {
159+
case 'Enter':
160+
handleToolbarButtonClick(item, null, false);
161+
break;
162+
case 'Escape':
163+
closeDropdowns();
164+
break;
165+
case 'ArrowRight':
166+
closeDropdowns();
167+
moveToNextButton(e);
168+
break;
169+
case 'ArrowLeft':
170+
closeDropdowns();
171+
moveToPreviousButton(e);
172+
break;
173+
case 'Tab':
174+
if (e.shiftKey) {
175+
moveToPreviousButtonGroup(e);
176+
} else {
177+
moveToNextButtonGroup(e);
178+
}
179+
break;
180+
default:
181+
break;
182+
}
183+
};
184+
const handleFocus = (e) => {
185+
// Set the focus to the first button inside the button group that is not disabled
186+
const firstButton = e.target.closest('.button-group').querySelector('.toolbar-item-ctn:not(.disabled)');
187+
if (firstButton) {
188+
firstButton.focus();
189+
}
190+
};
108191
</script>
109192
110193
<template>
111194
<div
112195
:style="getPositionStyle"
113-
class="button-group"
196+
class="button-group"
114197
role="group"
198+
@focus="handleFocus"
115199
>
116-
<div v-for="item in toolbarItems" :key="item.id.value" :class="{
200+
<div
201+
v-for="(item, index) in toolbarItems"
202+
:key="item.id.value"
203+
:class="{
117204
narrow: item.isNarrow.value,
118205
wide: item.isWide.value,
119-
}" class="toolbar-item-ctn">
206+
disabled: item.disabled.value,
207+
}"
208+
@keydown="(e) => handleKeyDown(e, item)"
209+
class="toolbar-item-ctn"
210+
:tabindex="index === 0 ? 0 : -1"
211+
>
120212
<!-- toolbar separator -->
121213
<ToolbarSeparator v-if="isSeparator(item)" style="width: 20px" />
122214
@@ -140,8 +232,12 @@ const handleClickOutside = (e) => {
140232
>
141233
<n-tooltip trigger="hover" :disabled="!item.tooltip?.value">
142234
<template #trigger>
143-
<ToolbarButton :toolbar-item="item" @textSubmit="handleToolbarButtonTextSubmit(item, $event)"
144-
@buttonClick="handleToolbarButtonClick(item)" />
235+
<ToolbarButton
236+
:toolbar-item="item"
237+
:disabled="item.disabled.value"
238+
@textSubmit="handleToolbarButtonTextSubmit(item, $event)"
239+
@buttonClick="handleToolbarButtonClick(item)"
240+
/>
145241
</template>
146242
<div>
147243
{{ item.tooltip }}

packages/super-editor/src/components/toolbar/Toolbar.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ const onWindowResized = async () => {
4646
};
4747
const onResizeThrottled = throttle(onWindowResized, 300);
4848
49-
const handleCommand = ({ item, argument, option }) => {
50-
proxy.$toolbar.emitCommand({ item, argument, option });
49+
const handleCommand = ({ item, argument, option, switchFocusToEditor = true }) => {
50+
proxy.$toolbar.emitCommand({ item, argument, option, switchFocusToEditor });
5151
};
52+
5253
</script>
5354
5455
<template>
@@ -59,19 +60,22 @@ const handleCommand = ({ item, argument, option }) => {
5960
aria-label="Toolbar"
6061
>
6162
<ButtonGroup
63+
tabindex="0"
6264
v-if="showLeftSide"
6365
:toolbar-items="getFilteredItems('left')"
6466
position="left"
6567
@command="handleCommand"
6668
class="superdoc-toolbar-group-side"
6769
/>
6870
<ButtonGroup
71+
tabindex="0"
6972
:toolbar-items="getFilteredItems('center')"
7073
:overflow-items="proxy.$toolbar.overflowItems"
7174
position="center"
7275
@command="handleCommand"
7376
/>
7477
<ButtonGroup
78+
tabindex="0"
7579
v-if="showRightSide"
7680
:toolbar-items="getFilteredItems('right')"
7781
position="right"

packages/super-editor/src/components/toolbar/ToolbarButton.vue

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,16 @@ const caretIcon = computed(() => {
9090
<div
9191
:class="['toolbar-item', attributes.className]"
9292
:style="getStyle"
93-
:role="isOverflowItem ? 'menuitem' : ''"
93+
:role="isOverflowItem ? 'menuitem' : 'button'"
94+
:aria-label="attributes.ariaLabel"
95+
@click="handleClick"
96+
@keydown.enter.stop="handleClick"
97+
tabindex="0"
9498
>
9599
<div
96-
@click="handleClick"
97100
class="toolbar-button"
98101
:class="{ active, disabled, narrow: isNarrow, wide: isWide, 'has-inline-text-input': hasInlineTextInput, 'high-contrast': isHighContrastMode }"
99102
:data-item="`btn-${name || ''}`"
100-
:aria-label="attributes.ariaLabel"
101-
role="button"
102103
>
103104
<ToolbarButtonIcon v-if="icon" :color="iconColor" class="toolbar-icon" :icon="icon" :name="name">
104105
</ToolbarButtonIcon>
@@ -110,15 +111,15 @@ const caretIcon = computed(() => {
110111
<span v-if="inlineTextInputVisible">
111112
<input v-if="name === 'fontSize'" v-model="inlineTextInput" @input="onFontSizeInput" :placeholder="label"
112113
@keydown.enter.prevent="handleInputSubmit" type="text" class="button-text-input"
113-
:class="{ 'high-contrast': isHighContrastMode }" :id="'inlineTextInput-' + name" autoccomplete="off"
114-
ref="inlineInput" />
114+
:class="{ 'high-contrast': isHighContrastMode }" :id="'inlineTextInput-' + name" autocomplete="off" ref="inlineInput" />
115115
<input v-else v-model="inlineTextInput" :placeholder="label" @keydown.enter.prevent="handleInputSubmit"
116-
type="text" class="button-text-input" :id="'inlineTextInput-' + name" autoccomplete="off" ref="inlineInput" />
116+
type="text" class="button-text-input" :id="'inlineTextInput-' + name" autocomplete="off" ref="inlineInput" />
117117
</span>
118118
119119
<div v-if="hasCaret" class="dropdown-caret" v-html="caretIcon" :style="{ opacity: disabled ? 0.6 : 1 }">
120120
</div>
121121
122+
<div aria-live="polite" class="visually-hidden">{{ `${attributes.ariaLabel} ${active ? 'selected' : 'unset'}` }}</div>
122123
</div>
123124
</div>
124125
</template>
@@ -131,6 +132,14 @@ const caretIcon = computed(() => {
131132
margin: 0 1px;
132133
}
133134
135+
.visually-hidden {
136+
position: absolute;
137+
left: -9999px;
138+
height: 1px;
139+
width: 1px;
140+
overflow: hidden;
141+
}
142+
134143
.toolbar-button {
135144
padding: 5px;
136145
height: 32px;

packages/super-editor/src/components/toolbar/super-toolbar.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -822,8 +822,8 @@ export class SuperToolbar extends EventEmitter {
822822
* @param {*} [params.argument] - The argument passed to the command
823823
* @returns {*} The result of the executed command, undefined if no result is returned
824824
*/
825-
emitCommand({ item, argument, option }) {
826-
if (this.activeEditor && !this.activeEditor.options.isHeaderOrFooter) {
825+
emitCommand({ item, argument, option, switchFocusToEditor = true }) {
826+
if (this.activeEditor && !this.activeEditor.options.isHeaderOrFooter && switchFocusToEditor) {
827827
this.activeEditor.focus();
828828
}
829829

0 commit comments

Comments
 (0)