Skip to content

Commit

Permalink
feat(MenuBar): Make the menu bar role=toolbar and add focus handling
Browse files Browse the repository at this point in the history
For accessibility the toolbar should be implemented using `role=toolbar`.
This requires custom focus handling[1] this is done by making only one element tabable.

[1] https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/toolbar_role#description

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux authored and juliusknorr committed Oct 20, 2023
1 parent 3303831 commit e7c33bb
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 74 deletions.
49 changes: 0 additions & 49 deletions src/components/Menu/ActionEntry.js

This file was deleted.

33 changes: 33 additions & 0 deletions src/components/Menu/BaseActionEntry.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const BaseActionEntry = {
type: Object,
required: true,
},
canBeFocussed: {
type: Boolean,
default: null,
},
},
data() {
return {
Expand Down Expand Up @@ -72,10 +76,20 @@ const BaseActionEntry = {
].join(' ')
},
},
watch: {
/** Handle tabindex for menu toolbar */
canBeFocussed() {
this.setTabIndexOnButton()
},
},
mounted() {
this.$_updateState = debounce(this.updateState.bind(this), 50)
this.$editor.on('update', this.$_updateState)
this.$editor.on('selectionUpdate', this.$_updateState)
// Initially emit the disabled event to set the state in parent
this.$emit('disabled', this.state.disabled)
// Initially set the tabindex
this.setTabIndexOnButton()
},
beforeDestroy() {
this.$editor.off('update', this.$_updateState)
Expand All @@ -84,6 +98,25 @@ const BaseActionEntry = {
methods: {
updateState() {
this.state = getActionState(this.actionEntry, this.$editor)
this.$emit('disabled', this.state.disabled)
},
setTabIndexOnButton() {
/** @type {HTMLButtonElement} */
const button = this.$el.tagName.toLowerCase() === 'button' ? this.$el : this.$el.querySelector('button')

if (this.canBeFocussed === null) {
button.removeAttribute('tabindex')
} else {
button.setAttribute('tabindex', this.canBeFocussed ? '0' : '-1')
}
},
/**
* Focus the inner button of this action
*/
focusButton() {
/** @type {HTMLButtonElement} */
const button = this.$el.tagName.toLowerCase() === 'button' ? this.$el : this.$el.querySelector('button')
button.focus()
},
},
}
Expand Down
49 changes: 32 additions & 17 deletions src/components/Menu/MenuBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
<div :id="randomID"
class="text-menubar"
data-text-el="menubar"
role="menubar"
:aria-label="t('text', 'Formatting menu bar')"
role="region"
:aria-label="t('text', 'Editor actions')"
:class="{
'text-menubar--ready': isReady,
'text-menubar--show': isVisible,
Expand All @@ -42,14 +42,26 @@

<div v-if="$isRichEditor"
ref="menubar"
role="group"
role="toolbar"
class="text-menubar__entries"
:aria-label="t('text', 'Editor actions')">
<ActionEntry v-for="actionEntry of visibleEntries"
v-bind="{ actionEntry }"
:key="`text-action--${actionEntry.key}`" />
<ActionList key="text-action--remain"
:action-entry="hiddenEntries">
:aria-label="t('text', 'Formatting menu bar')"
@keydown.left.stop="handleToolbarNavigation"
@keydown.right.stop="handleToolbarNavigation">
<!-- The visible inline actions -->
<component :is="actionEntry.component ? actionEntry.component : (actionEntry.children ? 'ActionList' : 'ActionSingle')"
v-for="actionEntry, index of visibleEntries"
ref="menuEntries"
:key="actionEntry.key"
:action-entry="actionEntry"
:can-be-focussed="activeMenuEntry === index"
@disabled="disableMenuEntry(actionEntry.key, $event)"
@click="activeMenuEntry = index" />

<!-- The remaining actions -->
<ActionList ref="remainingEntries"
:action-entry="hiddenEntries"
:can-be-focussed="activeMenuEntry === visibleEntries.length"
@click="activeMenuEntry = 'remain'">
<template #lastAction="{ visible }">
<NcActionButton @click="showTranslate">
<template #icon>
Expand All @@ -75,34 +87,36 @@ import { loadState } from '@nextcloud/initial-state'
import debounce from 'debounce'
import { useResizeObserver } from '@vueuse/core'
import ActionFormattingHelp from './ActionFormattingHelp.vue'
import ActionList from './ActionList.vue'
import ActionSingle from './ActionSingle.vue'
import CharacterCount from './CharacterCount.vue'
import HelpModal from '../HelpModal.vue'
import ToolBarLogic from './ToolBarLogic.js'
import Translate from './../Modal/Translate.vue'
import actionsFullEntries from './entries.js'
import ActionEntry from './ActionEntry.js'
import { MENU_ID } from './MenuBar.provider.js'
import ActionList from './ActionList.vue'
import { DotsHorizontal, TranslateVariant } from '../icons.js'
import {
useEditorMixin,
useIsRichEditorMixin,
useIsRichWorkspaceMixin,
} from '../Editor.provider.js'
import ActionFormattingHelp from './ActionFormattingHelp.vue'
import CharacterCount from './CharacterCount.vue'
import Translate from './../Modal/Translate.vue'
export default {
name: 'MenuBar',
components: {
ActionEntry,
ActionFormattingHelp,
ActionList,
ActionSingle,
HelpModal,
NcActionSeparator,
NcActionButton,
CharacterCount,
TranslateVariant,
Translate,
},
extends: ToolBarLogic,
mixins: [
useEditorMixin,
useIsRichEditorMixin,
Expand All @@ -127,6 +141,7 @@ export default {
},
data() {
return {
entries: [...actionsFullEntries],
randomID: `menu-bar-${(Math.ceil((Math.random() * 10000) + 500)).toString(16)}`,
displayHelp: false,
displayTranslate: false,
Expand All @@ -139,7 +154,7 @@ export default {
},
computed: {
visibleEntries() {
const list = [...actionsFullEntries].filter(({ priority }) => {
const list = this.entries.filter(({ priority }) => {
// if entry do not have priority, we assume it aways will be visible
return priority === undefined || priority <= this.iconsLimit
})
Expand All @@ -151,7 +166,7 @@ export default {
key: 'remain',
label: this.t('text', 'Remaining actions'),
icon: DotsHorizontal,
children: [...actionsFullEntries].filter(({ priority }) => {
children: this.entries.filter(({ priority }) => {
// reverse logic from visibleEntries
return priority !== undefined && priority > this.iconsLimit
}),
Expand Down
27 changes: 19 additions & 8 deletions src/components/Menu/ReadonlyBar.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
<template>
<div data-text-el="readonly-bar" class="text-readonly-bar">
<div ref="menubar"
role="group"
role="toolbar"
class="text-readonly-bar__entries"
:aria-label="t('text', 'Editor actions')">
<ActionEntry v-for="actionEntry of visibleEntries"
v-bind="{ actionEntry }"
:key="`text-action--${actionEntry.key}`" />
<component :is="actionEntry.component ? actionEntry.component : (actionEntry.children ? 'ActionList' : 'ActionSingle')"
v-for="actionEntry, index of visibleEntries"
ref="menuEntries"
:key="actionEntry.key"
:action-entry="actionEntry"
:can-be-focussed="activeMenuEntry === index"
@disabled="disableMenuEntry(actionEntry.key, $event)" />
</div>
<div class="text-menubar__slot">
<slot />
Expand All @@ -17,14 +21,21 @@
<script>
import { defineComponent } from 'vue'
import { ReadonlyEntries as entries } from './entries.js'
import ActionEntry from './ActionEntry.js'
import ActionList from './ActionList.vue'
import ActionSingle from './ActionSingle.vue'
import ToolBarLogic from './ToolBarLogic.js'
export default defineComponent({
name: 'ReadonlyBar',
components: { ActionEntry },
setup() {
components: {
ActionList,
ActionSingle,
},
extends: ToolBarLogic,
data() {
return {
visibleEntries: entries,
entries,
}
},
})
Expand Down
82 changes: 82 additions & 0 deletions src/components/Menu/ToolBarLogic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { defineComponent } from 'vue'

export default defineComponent({
data() {
return {
/** Current menu entry that has focus */
activeMenuEntry: 0,
entries: [],
}
},
computed: {
visibleEntries() {
return this.entries
},
},
watch: {
visibleEntries() {
this.$nextTick(() => {
if (this.activeMenuEntry > this.visibleEntries.length || this.visibleEntries[this.activeMenuEntry].disabled) {
this.setNextMenuEntry()
}
})
},
},
methods: {
/**
* Update the disabled state of an menu entry
* @param {string} menuKey The key of the menu entry that changed
* @param {boolean} state The new disabled state
*/
disableMenuEntry(menuKey, state) {
const index = this.visibleEntries.findIndex(({ key }) => key === menuKey)
this.visibleEntries[index].disabled = state
if (state === false && this.activeMenuEntry === index) {
this.$nextTick(() => this.setNextMenuEntry())
}
},
/**
* Set the active menu entry to the next one (or reset to first)
*/
setNextMenuEntry() {
// refs is not reactive so we must check this every time
const modulo = this.visibleEntries.length + (this.$refs.remainingEntries ? 1 : 0)

do {
this.activeMenuEntry = (this.activeMenuEntry + 1) % modulo
} while (this.activeMenuEntry < this.visibleEntries.length && this.visibleEntries[this.activeMenuEntry].disabled)
},
/**
* Set the active menu entry to the previous one (or reset to last entry (remaining actions))
*/
setPreviousMenuEntry() {
// refs is not reactive so we must check this every time
const modulo = this.visibleEntries.length + (this.$refs.remainingEntries ? 1 : 0)

do {
const index = this.activeMenuEntry - 1
this.activeMenuEntry = ((index % modulo) + modulo) % modulo // needed as JS does not work with negative modulos
} while (this.activeMenuEntry < this.visibleEntries.length && this.visibleEntries[this.activeMenuEntry].disabled)
},

/**
* Handle navigation in toolbar
* @param {KeyboardEvent} event The keydown event
*/
handleToolbarNavigation(event) {
if (event.key === 'ArrowRight') {
this.setNextMenuEntry()
} else if (event.key === 'ArrowLeft') {
this.setPreviousMenuEntry()
}

if (this.activeMenuEntry === this.visibleEntries.length) {
this.$refs.remainingEntries?.focusButton?.()
} else {
// The ref is in no order (ordered by the time they needed to mount), so we need to order them like they are shown on the menu
const entries = [...this.$refs.menuEntries].sort((a, b) => this.visibleEntries.findIndex(({ key }) => key === a.$vnode.data.key) - this.visibleEntries.findIndex(({ key }) => key === b.$vnode.data.key))
entries[this.activeMenuEntry].focusButton()
}
},
},
})

0 comments on commit e7c33bb

Please sign in to comment.