Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
@minimize="emitMinimize"
/>
<MobileFormattingBar
v-if="isFocused"
v-if="isFocused || formattingBarHasFocus"
v-on="sharedEventHandlers"
@focusin.native="formattingBarHasFocus = true"
@focusout.native="handleFormattingBarFocusout"
/>
</div>
</div>
Expand Down Expand Up @@ -167,6 +169,13 @@
'insert-math': target => mathHandler.openCreateMathModal({ targetElement: target }),
}));

// Tabbing into the bar blurs the editor, which would otherwise unmount
// the bar before focus lands on it.
const formattingBarHasFocus = ref(false);
const handleFormattingBarFocusout = event => {
formattingBarHasFocus.value = event.currentTarget.contains(event.relatedTarget);
};

const handleDrop = event => {
const file = event.dataTransfer.files[0];
if (file) {
Expand Down Expand Up @@ -279,6 +288,8 @@
editorContainer,
isReady,
isFocused,
formattingBarHasFocus,
handleFormattingBarFocusout,
handleDrop,
linkHandler,
editor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
<template #more="{ overflowItems }">
<button
class="more-button"
data-toolbar-item
:class="
$computedClass({
':is([aria-expanded=\'true\'])': {
Expand Down Expand Up @@ -142,6 +143,7 @@
import { useToolbarActions } from '../composables/useToolbarActions';
import { getTipTapEditorStrings } from '../TipTapEditorStrings';
import { useDropdowns } from '../composables/useDropdowns';
import { useRovingTabIndex } from '../composables/useRovingTabIndex';
import ToolbarButton from './toolbar/ToolbarButton.vue';
import FormatDropdown from './toolbar/FormatDropdown.vue';
import PasteDropdown from './toolbar/PasteDropdown.vue';
Expand All @@ -157,6 +159,7 @@
},
setup(props, { emit }) {
const toolbarRef = ref(null);
useRovingTabIndex(toolbarRef);

const {
handleCopy,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>

<div
ref="toolbarRef"
class="link-bubble-menu"
role="toolbar"
:aria-label="linkActions$()"
Expand All @@ -9,6 +10,7 @@
:href="href"
target="_blank"
class="link-url"
data-toolbar-item
:aria-label="`${goToLink$()} ${opensInNewTab$()}`"
>
{{ goToLink$() }}
Expand All @@ -22,6 +24,7 @@

<button
class="bubble-menu-button"
data-toolbar-item
:title="copyLink$()"
:aria-label="copyLink$()"
@click="copyToClipboard(href)"
Expand All @@ -35,6 +38,7 @@

<button
class="bubble-menu-button"
data-toolbar-item
:title="editLink$()"
:aria-label="editLink$()"
@click="onEdit"
Expand All @@ -48,6 +52,7 @@

<button
class="bubble-menu-button"
data-toolbar-item
:title="removeLink$()"
:aria-label="removeLink$()"
@click="onRemove"
Expand All @@ -65,12 +70,17 @@

<script>

import { defineComponent, computed, inject } from 'vue';
import { defineComponent, computed, inject, ref } from 'vue';
import { getTipTapEditorStrings } from '../../TipTapEditorStrings';
import { useRovingTabIndex } from '../../composables/useRovingTabIndex';

export default defineComponent({
name: 'LinkBubbleMenu',
setup(props) {
const toolbarRef = ref(null);

useRovingTabIndex(toolbarRef);

const { goToLink$, copyLink$, editLink$, removeLink$, linkActions$, opensInNewTab$ } =
getTipTapEditorStrings();

Expand All @@ -82,6 +92,7 @@
};

return {
toolbarRef,
href,
onEdit: () => openLinkEditor('edit'),
onRemove: removeLink,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<button
ref="dropdownButton"
class="format-dropdown"
data-toolbar-item
:aria-expanded="isOpen"
:aria-haspopup="true"
:aria-label="textFormatOptions$()"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>

<div
ref="toolbarRef"
class="floating-panel"
role="toolbar"
:aria-label="textFormattingToolbar$()"
Expand All @@ -12,6 +13,7 @@
>
<button
class="toggle-btn"
data-toolbar-item
:title="isExpanded ? collapseFormattingBar$() : expandFormattingBar$()"
:aria-label="isExpanded ? collapseFormattingBar$() : expandFormattingBar$()"
:aria-expanded="isExpanded"
Expand All @@ -36,7 +38,8 @@
:aria-label="formatSize$()"
>
<button
:disabled="!canDecreaseFormat"
data-toolbar-item
:aria-disabled="canDecreaseFormat ? 'false' : 'true'"
:title="decreaseFormatSize$()"
:aria-label="decreaseFormatSize$()"
class="format-btn"
Expand All @@ -51,7 +54,8 @@
aria-hidden="true"
>
<button
:disabled="!canIncreaseFormat"
data-toolbar-item
:aria-disabled="canIncreaseFormat ? 'false' : 'true'"
:title="increaseFormatSize$()"
:aria-label="increaseFormatSize$()"
class="format-btn"
Expand Down Expand Up @@ -122,6 +126,7 @@
import { useToolbarActions } from '../../composables/useToolbarActions';
import { useFormatControls } from '../../composables/useFormatControls';
import { getTipTapEditorStrings } from '../../TipTapEditorStrings';
import { useRovingTabIndex } from '../../composables/useRovingTabIndex';
import ToolbarButton from './ToolbarButton.vue';
import ToolbarDivider from './ToolbarDivider.vue';

Expand All @@ -132,6 +137,9 @@
const isExpanded = ref(true);
const keyboardOffset = ref(0);
const editor = inject('editor');
const toolbarRef = ref(null);

useRovingTabIndex(toolbarRef);

const {
collapseFormattingBar$,
Expand Down Expand Up @@ -207,6 +215,7 @@
return {
isExpanded,
keyboardOffset,
toolbarRef,
textActions,
listActions,
insertTools,
Expand Down Expand Up @@ -306,7 +315,7 @@
border-radius: 0.25rem;
}

.format-btn:disabled {
.format-btn[aria-disabled='true'] {
color: #d1d5da;
cursor: not-allowed;
border-color: #e1e5e9;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>

<div
ref="toolbarRef"
class="toolbar top-bar"
role="toolbar"
:aria-label="editorControls$()"
Expand All @@ -24,6 +25,7 @@
<div class="topbar-actions">
<button
class="insert-button"
data-toolbar-item
:title="insertContent$()"
:aria-label="insertContentMenu$()"
:aria-expanded="isInsertMenuOpen"
Expand Down Expand Up @@ -75,6 +77,7 @@
import { defineComponent, ref, onMounted, onBeforeUnmount } from 'vue';
import { useToolbarActions } from '../../composables/useToolbarActions';
import { getTipTapEditorStrings } from '../../TipTapEditorStrings';
import { useRovingTabIndex } from '../../composables/useRovingTabIndex';
import ToolbarButton from './ToolbarButton.vue';

export default defineComponent({
Expand All @@ -83,6 +86,9 @@
setup(props, { emit }) {
const isInsertMenuOpen = ref(false);
const dropdown = ref(null);
const toolbarRef = ref(null);

useRovingTabIndex(toolbarRef);

const { historyActions, insertTools, minimizeAction } = useToolbarActions(emit);

Expand Down Expand Up @@ -120,6 +126,7 @@
minimizeAction,
isInsertMenuOpen,
dropdown,
toolbarRef,
editorControls$,
historyActions$,
insertContent$,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
>
<button
class="paste-main-btn toolbar-btn"
data-toolbar-item
:title="paste$()"
:aria-label="paste$()"
@click="handlePaste"
Expand All @@ -21,6 +22,7 @@
<button
ref="dropdownButton"
class="paste-dropdown-btn"
data-toolbar-item
:title="pasteOptions$()"
:aria-expanded="isOpen"
:aria-haspopup="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
class="toolbar-btn"
:title="title"
:class="{ active: isActive, disabled: !isAvailable }"
:disabled="!isAvailable"
:tabindex="isAvailable ? 0 : -1"
data-toolbar-item
:aria-disabled="isAvailable ? 'false' : 'true'"
:aria-label="title"
:aria-pressed="isActive ? 'true' : 'false'"
@mousedown.prevent
Expand Down Expand Up @@ -143,12 +143,6 @@
opacity: 0.3;
}

.toolbar-btn:disabled {
pointer-events: none;
cursor: not-allowed;
opacity: 0.3;
}

.toolbar-icon.rtl-flip {
transform: scaleX(-1);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { onMounted, onUnmounted } from 'vue';

const TOOLBAR_ITEM_SELECTOR = '[data-toolbar-item]';

/**
* Roving tabindex over the `[data-toolbar-item]` controls inside `containerRef`,
* per the WAI-ARIA APG toolbar pattern.
*
* Controls must not bind `tabindex` themselves — a re-render would strip the
* toolbar's only tab stop.
*
* @param {import('vue').Ref<HTMLElement>} containerRef - the `role="toolbar"` element.
*/
export function useRovingTabIndex(containerRef) {
// Vue clears template refs before `onUnmounted`, so hold the element itself
// for the lifetime of the listeners.
let container = null;
let activeItem = null;
let observer = null;

// KListWithOverflow leaves overflowed controls in the DOM and hides them by
// setting `visibility` on their wrapper, so only the computed value shows it.
const getItems = () =>
Array.from(container.querySelectorAll(TOOLBAR_ITEM_SELECTOR)).filter(
item => window.getComputedStyle(item).visibility !== 'hidden',
);

const syncTabIndexes = () => {
const items = getItems();
if (!items.includes(activeItem)) {
activeItem = items[0] || null;
}
items.forEach(item => item.setAttribute('tabindex', item === activeItem ? '0' : '-1'));
};

// Page direction comes from `<html dir>`, rendered server-side by `base.html`.
const isRtl = () => document.documentElement.dir === 'rtl';

const handleKeydown = event => {
const step = { ArrowRight: 1, ArrowLeft: -1 }[event.key];
if (!step) {
return;
}
// Open menus own their arrow keys; a control must not become navigable
// just because a menu was nested inside it.
if (event.target.closest('[role="menu"]')) {
return;
}
const items = getItems();
const index = items.indexOf(event.target.closest(TOOLBAR_ITEM_SELECTOR));
if (index === -1) {
return;
}
event.preventDefault();
const offset = isRtl() ? -step : step;
activeItem = items[(index + offset + items.length) % items.length];
syncTabIndexes();
activeItem.focus();
};

// Tabbing back into the toolbar must return to the control that last had focus.
const handleFocusin = event => {
const item = event.target.closest(TOOLBAR_ITEM_SELECTOR);
if (item && getItems().includes(item)) {
activeItem = item;
syncTabIndexes();
}
};

onMounted(() => {
container = containerRef.value;
container.addEventListener('keydown', handleKeydown);
container.addEventListener('focusin', handleFocusin);
observer = new MutationObserver(syncTabIndexes);
// Filtering to `style` — the attribute that hides overflowed controls — also
// keeps our own `tabindex` writes from re-triggering this.
observer.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style'],
});
syncTabIndexes();
});

onUnmounted(() => {
container.removeEventListener('keydown', handleKeydown);
container.removeEventListener('focusin', handleFocusin);
observer.disconnect();
});
}
Loading
Loading