Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/super-editor/src/components/SuperEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,20 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } =
};

const handleSuperEditorKeydown = (event) => {
// cmd/ctrl + opt/alt + shift + M
if ((event.metaKey || event.ctrlKey) && event.altKey && event.shiftKey) {
if (event.code === 'KeyM') {
const toolbar = document.querySelector('.superdoc-toolbar');
if (toolbar) {
toolbar.setAttribute('tabindex', '0');
toolbar.focus();
}
}
}
emit('editor-keydown', { editor: editor.value });
};


const handleSuperEditorClick = (event) => {
emit('editor-click', { editor: editor.value });
let pmElement = editorElem.value?.querySelector('.ProseMirror');
Expand Down
112 changes: 104 additions & 8 deletions packages/super-editor/src/components/toolbar/ButtonGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ const isButton = (item) => item.type === 'button';
const isDropdown = (item) => item.type === 'dropdown';
const isSeparator = (item) => item.type === 'separator';
const isOverflow = (item) => item.type === 'overflow';
const handleToolbarButtonClick = (item, argument = null) => {
const handleToolbarButtonClick = (item, argument = null, switchFocusToEditor = true) => {
currentItem.value = item;
currentItem.value.expand = true;
if (item.disabled.value) return;
emit('command', { item, argument });
emit('command', { item, argument, switchFocusToEditor });
};

const handleToolbarButtonTextSubmit = (item, argument) => {
Expand All @@ -78,7 +78,7 @@ const selectedOption = ref(null);
const handleSelect = (item, option) => {
closeDropdowns();
const value = item.dropdownValueKey.value ? option[item.dropdownValueKey.value] : option.label;
emit('command', { item, argument: value, option });
emit('command', { item, argument: value, option, switchFocusToEditor: true });
selectedOption.value = option.key;
};

Expand All @@ -105,18 +105,110 @@ const getDropdownAttributes = (option, item) => {
const handleClickOutside = (e) => {
closeDropdowns();
};


const moveToNextButton = (e) => {
const currentButton = e.target;
const nextButton = e.target.closest('.toolbar-item-ctn').nextElementSibling;
if (nextButton) {
currentButton.setAttribute('tabindex', '-1');
nextButton.setAttribute('tabindex', '0');
nextButton.focus();
}
};

const moveToPreviousButton = (e) => {
const currentButton = e.target;
const previousButton = e.target.closest('.toolbar-item-ctn').previousElementSibling;
if (previousButton) {
currentButton.setAttribute('tabindex', '-1');
previousButton.setAttribute('tabindex', '0');
previousButton.focus();
}
};

const moveToNextButtonGroup = (e) => {
const nextButtonGroup = e.target.closest('.button-group').nextElementSibling;
if (nextButtonGroup) {
nextButtonGroup.setAttribute('tabindex', '0');
nextButtonGroup.focus();
} else {
// Move to the editor
const editor = document.querySelector('.ProseMirror');
if (editor) {
editor.focus();
}
}
};

const moveToPreviousButtonGroup = (e) => {
const previousButtonGroup = e.target.closest('.button-group').previousElementSibling;
if (previousButtonGroup) {
previousButtonGroup.setAttribute('tabindex', '0');
previousButtonGroup.focus();
}
};

// Implement keyboard navigation using Roving Tabindex
// https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex
// Set tabindex to 0 for the current focused button
// Set tabindex to -1 for all other buttons
const handleKeyDown = (e, item) => {
e.preventDefault();
switch (e.key) {
case 'Enter':
handleToolbarButtonClick(item, null, false);
break;
case 'Escape':
closeDropdowns();
break;
case 'ArrowRight':
closeDropdowns();
moveToNextButton(e);
break;
case 'ArrowLeft':
closeDropdowns();
moveToPreviousButton(e);
break;
case 'Tab':
if (e.shiftKey) {
moveToPreviousButtonGroup(e);
} else {
moveToNextButtonGroup(e);
}
break;
default:
break;
}
};
const handleFocus = (e) => {
// Set the focus to the first button inside the button group that is not disabled
const firstButton = e.target.closest('.button-group').querySelector('.toolbar-item-ctn:not(.disabled)');
if (firstButton) {
firstButton.focus();
}
};
</script>

<template>
<div
:style="getPositionStyle"
class="button-group"
class="button-group"
role="group"
@focus="handleFocus"
>
<div v-for="item in toolbarItems" :key="item.id.value" :class="{
<div
v-for="(item, index) in toolbarItems"
:key="item.id.value"
:class="{
narrow: item.isNarrow.value,
wide: item.isWide.value,
}" class="toolbar-item-ctn">
disabled: item.disabled.value,
}"
@keydown="(e) => handleKeyDown(e, item)"
class="toolbar-item-ctn"
:tabindex="index === 0 ? 0 : -1"
>
<!-- toolbar separator -->
<ToolbarSeparator v-if="isSeparator(item)" style="width: 20px" />

Expand All @@ -140,8 +232,12 @@ const handleClickOutside = (e) => {
>
<n-tooltip trigger="hover" :disabled="!item.tooltip?.value">
<template #trigger>
<ToolbarButton :toolbar-item="item" @textSubmit="handleToolbarButtonTextSubmit(item, $event)"
@buttonClick="handleToolbarButtonClick(item)" />
<ToolbarButton
:toolbar-item="item"
:disabled="item.disabled.value"
@textSubmit="handleToolbarButtonTextSubmit(item, $event)"
@buttonClick="handleToolbarButtonClick(item)"
/>
</template>
<div>
{{ item.tooltip }}
Expand Down
8 changes: 6 additions & 2 deletions packages/super-editor/src/components/toolbar/Toolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ const onWindowResized = async () => {
};
const onResizeThrottled = throttle(onWindowResized, 300);

const handleCommand = ({ item, argument, option }) => {
proxy.$toolbar.emitCommand({ item, argument, option });
const handleCommand = ({ item, argument, option, switchFocusToEditor = true }) => {
proxy.$toolbar.emitCommand({ item, argument, option, switchFocusToEditor });
};

</script>

<template>
Expand All @@ -59,19 +60,22 @@ const handleCommand = ({ item, argument, option }) => {
aria-label="Toolbar"
>
<ButtonGroup
tabindex="0"
v-if="showLeftSide"
:toolbar-items="getFilteredItems('left')"
position="left"
@command="handleCommand"
class="superdoc-toolbar-group-side"
/>
<ButtonGroup
tabindex="0"
:toolbar-items="getFilteredItems('center')"
:overflow-items="proxy.$toolbar.overflowItems"
position="center"
@command="handleCommand"
/>
<ButtonGroup
tabindex="0"
v-if="showRightSide"
:toolbar-items="getFilteredItems('right')"
position="right"
Expand Down
23 changes: 16 additions & 7 deletions packages/super-editor/src/components/toolbar/ToolbarButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,16 @@ const caretIcon = computed(() => {
<div
:class="['toolbar-item', attributes.className]"
:style="getStyle"
:role="isOverflowItem ? 'menuitem' : ''"
:role="isOverflowItem ? 'menuitem' : 'button'"
:aria-label="attributes.ariaLabel"
@click="handleClick"
@keydown.enter.stop="handleClick"
tabindex="0"
>
<div
@click="handleClick"
class="toolbar-button"
:class="{ active, disabled, narrow: isNarrow, wide: isWide, 'has-inline-text-input': hasInlineTextInput, 'high-contrast': isHighContrastMode }"
:data-item="`btn-${name || ''}`"
:aria-label="attributes.ariaLabel"
role="button"
>
<ToolbarButtonIcon v-if="icon" :color="iconColor" class="toolbar-icon" :icon="icon" :name="name">
</ToolbarButtonIcon>
Expand All @@ -110,15 +111,15 @@ const caretIcon = computed(() => {
<span v-if="inlineTextInputVisible">
<input v-if="name === 'fontSize'" v-model="inlineTextInput" @input="onFontSizeInput" :placeholder="label"
@keydown.enter.prevent="handleInputSubmit" type="text" class="button-text-input"
:class="{ 'high-contrast': isHighContrastMode }" :id="'inlineTextInput-' + name" autoccomplete="off"
ref="inlineInput" />
:class="{ 'high-contrast': isHighContrastMode }" :id="'inlineTextInput-' + name" autocomplete="off" ref="inlineInput" />
<input v-else v-model="inlineTextInput" :placeholder="label" @keydown.enter.prevent="handleInputSubmit"
type="text" class="button-text-input" :id="'inlineTextInput-' + name" autoccomplete="off" ref="inlineInput" />
type="text" class="button-text-input" :id="'inlineTextInput-' + name" autocomplete="off" ref="inlineInput" />
</span>

<div v-if="hasCaret" class="dropdown-caret" v-html="caretIcon" :style="{ opacity: disabled ? 0.6 : 1 }">
</div>

<div aria-live="polite" class="visually-hidden">{{ `${attributes.ariaLabel} ${active ? 'selected' : 'unset'}` }}</div>
</div>
</div>
</template>
Expand All @@ -131,6 +132,14 @@ const caretIcon = computed(() => {
margin: 0 1px;
}

.visually-hidden {
position: absolute;
left: -9999px;
height: 1px;
width: 1px;
overflow: hidden;
}

.toolbar-button {
padding: 5px;
height: 32px;
Expand Down
4 changes: 2 additions & 2 deletions packages/super-editor/src/components/toolbar/super-toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -822,8 +822,8 @@ export class SuperToolbar extends EventEmitter {
* @param {*} [params.argument] - The argument passed to the command
* @returns {*} The result of the executed command, undefined if no result is returned
*/
emitCommand({ item, argument, option }) {
if (this.activeEditor && !this.activeEditor.options.isHeaderOrFooter) {
emitCommand({ item, argument, option, switchFocusToEditor = true }) {
if (this.activeEditor && !this.activeEditor.options.isHeaderOrFooter && switchFocusToEditor) {
this.activeEditor.focus();
}

Expand Down