Skip to content

Commit

Permalink
fix(frontend): フォーカス/タブ移動に関する挙動を調整
Browse files Browse the repository at this point in the history
* MkMenu/popupMenu/contextMenu: フォーカストラップが正しく動作するように修正

* MkMediaList/PhotoSwipe: フォーカストラップが正しく動作するように修正

* scripts/focus.ts: focusNextの実装ミスを修正 & リファクタリング

* scripts/hotkey.ts: window.pswpが存在するときはHotkeyを無効にする
  • Loading branch information
taiyme committed May 29, 2024
1 parent 8724f72 commit 08a0368
Show file tree
Hide file tree
Showing 10 changed files with 139 additions and 73 deletions.
4 changes: 2 additions & 2 deletions packages/frontend/src/components/MkImgWithBlurhash.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
<img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/>
<img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/>
</TransitionGroup>
</div>
</template>
Expand Down
7 changes: 6 additions & 1 deletion packages/frontend/src/components/MkMediaImage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ SPDX-License-Identifier: AGPL-3.0-only
title: imageRef.name,
class: $style.imageContainer,
href: imageRef.url,
style: 'cursor: zoom-in;'
style: 'cursor: zoom-in;',
}"
tabindex="-1"
>
<MkImgWithBlurhash
:hash="imageRef.blurhash"
Expand Down Expand Up @@ -196,6 +197,10 @@ const showImageMenu = (ev: MouseEvent) => {
overflow: hidden; // fallback (overflow: clip)
overflow: clip;
border-radius: var(--mediaList-radius, 8px);

&:focus {
outline: none;
}
}

.rootVisible {
Expand Down
43 changes: 27 additions & 16 deletions packages/frontend/src/components/MkMediaList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import XAudio from '@/components/MkMediaAudio.vue';
import XBanner from '@/components/MkMediaBanner.vue';
import XImage from '@/components/MkMediaImage.vue';
import XVideo from '@/components/MkMediaVideo.vue';
import { focusParent } from '@/scripts/focus.js';

const EXPANDED_MIN_HEIGHT = 80 as const;

Expand All @@ -55,7 +56,9 @@ const gallery = shallowRef<HTMLDivElement>();
const pswpZIndex = claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
let lightbox: PhotoSwipeLightbox | null;
let lightbox: PhotoSwipeLightbox | null = null;

let activeEl: HTMLElement | null = null;

const popstateHandler = (): void => {
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
Expand All @@ -66,7 +69,7 @@ const popstateHandler = (): void => {
async function calcAspectRatio() {
if (!gallery.value) return;

let img = props.mediaList[0];
const img = props.mediaList[0];

if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
gallery.value.style.aspectRatio = '';
Expand Down Expand Up @@ -141,6 +144,7 @@ onMounted(() => {
bgOpacity: 1,
showAnimationDuration: 100,
hideAnimationDuration: 100,
returnFocus: false,
pswpModule: PhotoSwipe,
});

Expand Down Expand Up @@ -169,39 +173,46 @@ onMounted(() => {
lightbox.on('uiRegister', () => {
lightbox?.pswp?.ui?.registerElement({
name: 'altText',
className: 'pwsp__alt-text-container',
className: 'pswp__alt-text-container',
appendTo: 'wrapper',
onInit: (el, pwsp) => {
let textBox = document.createElement('p');
textBox.className = 'pwsp__alt-text _acrylic';
onInit: (el, pswp) => {
const textBox = document.createElement('p');
textBox.className = 'pswp__alt-text _acrylic';
el.appendChild(textBox);

pwsp.on('change', () => {
textBox.textContent = pwsp.currSlide?.data.comment;
pswp.on('change', () => {
textBox.textContent = pswp.currSlide?.data.comment;
});
},
});
});

lightbox.init();

window.addEventListener('popstate', popstateHandler);

lightbox.on('beforeOpen', () => {
lightbox.on('afterInit', () => {
activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
lightbox?.pswp?.element?.focus({
preventScroll: true,
});
history.pushState(null, '', '#pswp');
});

lightbox.on('close', () => {
lightbox.on('destroy', () => {
focusParent(activeEl, true, false);
activeEl = null;
if (window.location.hash === '#pswp') {
history.back();
}
});

window.addEventListener('popstate', popstateHandler);

lightbox.init();
});

onUnmounted(() => {
window.removeEventListener('popstate', popstateHandler);
lightbox?.destroy();
lightbox = null;
activeEl = null;
});

const previewable = (file: Misskey.entities.DriveFile): boolean => {
Expand Down Expand Up @@ -331,7 +342,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
backdrop-filter: var(--modalBgFilter);
}

.pwsp__alt-text-container {
.pswp__alt-text-container {
display: flex;
flex-direction: row;
align-items: center;
Expand All @@ -345,7 +356,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
max-width: 800px;
}

.pwsp__alt-text {
.pswp__alt-text {
color: var(--fg);
margin: 0 auto;
text-align: center;
Expand Down
4 changes: 3 additions & 1 deletion packages/frontend/src/components/MkMenu.child.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>

<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js';

Expand All @@ -27,6 +27,8 @@ const emit = defineEmits<{
(ev: 'actioned'): void;
}>();

provide('inChildMenu', true);

const el = shallowRef<HTMLElement>();
const align = 'left';

Expand Down
62 changes: 43 additions & 19 deletions packages/frontend/src/components/MkMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,72 +6,74 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div role="menu">
<div
ref="itemsEl" v-hotkey="keymap"
ref="itemsEl"
v-hotkey="keymap"
tabindex="-1"
class="_popup _shadow"
:class="[$style.root, { [$style.center]: align === 'center', [$style.asDrawer]: asDrawer }]"
:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
@contextmenu.self="e => e.preventDefault()"
>
<template v-for="(item, i) in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
<template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
<span style="opacity: 0.7;">{{ item.text }}</span>
</span>
<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
<span><MkEllipsis/></span>
</span>
<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkA v-else-if="item.type === 'link'" role="menuitem" tabindex="0" :class="['_button', $style.item]" :to="item.to" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</MkA>
<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<a v-else-if="item.type === 'a'" role="menuitem" tabindex="0" :class="['_button', $style.item]" :href="item.href" :target="item.target" :download="item.download" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</a>
<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button v-else-if="item.type === 'user'" role="menuitem" tabindex="0" :class="['_button', $style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
<div v-if="item.indicate" :class="$style.item_content">
<span :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</button>
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" tabindex="0" :class="['_button', $style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
<div :class="$style.item_content">
<span :class="[$style.item_content_text, { [$style.switchText]: !item.icon }]">{{ item.text }}</span>
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
</div>
</button>
<button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
<button v-else-if="item.type === 'radio'" role="menuitem" tabindex="0" :class="['_button', $style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button v-else-if="item.type === 'radioOption'" role="menuitem" tabindex="0" :class="['_button', $style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<div :class="$style.icon">
<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
</div>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
</div>
</button>
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
<button v-else-if="item.type === 'parent'" role="menuitem" tabindex="0" :class="['_button', $style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button v-else role="menuitem" tabindex="0" :class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
Expand All @@ -80,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
</template>
<span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]">
<span v-if="items2 == null || items2.length === 0" tabindex="0" :class="[$style.none, $style.item]">
<span>{{ i18n.ts.none }}</span>
</span>
</div>
Expand All @@ -91,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>

<script lang="ts">
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { ComputedRef, computed, defineAsyncComponent, inject, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
Expand Down Expand Up @@ -119,6 +121,8 @@ const emit = defineEmits<{
(ev: 'hide'): void;
}>();

const inChildMenu = inject<boolean>('inChildMenu', false);

const itemsEl = shallowRef<HTMLDivElement>();

const items2 = ref<InnerMenuItem[]>();
Expand Down Expand Up @@ -249,12 +253,24 @@ function close(actioned = false) {
emit('close', actioned);
}

function focusFirstItem() {
focusNext(itemsEl.value?.children.item(0), true, false);
}

function focusUp() {
focusPrev(document.activeElement);
if (document.activeElement === itemsEl.value) {
focusFirstItem();
} else {
focusPrev(document.activeElement);
}
}

function focusDown() {
focusNext(document.activeElement);
if (document.activeElement === itemsEl.value) {
focusFirstItem();
} else {
focusNext(document.activeElement);
}
}

function switchItem(item: MenuSwitch & { ref: any }) {
Expand All @@ -267,9 +283,13 @@ function getValue<T>(item?: ComputedRef<T> | T) {
}

onMounted(() => {
if (props.viaKeyboard) {
if (!inChildMenu) {
nextTick(() => {
if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false);
if (props.viaKeyboard) {
focusFirstItem();
} else {
itemsEl.value?.focus();
}
});
}

Expand All @@ -293,6 +313,10 @@ onBeforeUnmount(() => {
overflow: auto;
overscroll-behavior: contain;

&:focus-visible {
outline: none;
}

&.center {
> .item {
text-align: center;
Expand Down
6 changes: 3 additions & 3 deletions packages/frontend/src/components/MkNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="rootEl"
v-hotkey="keymap"
:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
:tabindex="!isDeleted ? '-1' : undefined"
:tabindex="isDeleted ? '-1' : '0'"
>
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
Expand Down Expand Up @@ -548,11 +548,11 @@ function blur() {
}

function focusBefore() {
focusPrev(rootEl.value ?? null);
focusPrev(rootEl.value);
}

function focusAfter() {
focusNext(rootEl.value ?? null);
focusNext(rootEl.value);
}

function readPromo() {
Expand Down
12 changes: 11 additions & 1 deletion packages/frontend/src/components/MkSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
<div ref="container" :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]" @mousedown.prevent="show">
<div
ref="container"
:class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]"
@mousedown.stop="show"
@keydown.stop.space.enter="show"
>
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
<select
ref="inputEl"
Expand All @@ -20,6 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only
@focus="focused = true"
@blur="focused = false"
@input="onInput"
@mousedown.prevent="() => {}"
@keydown.prevent="() => {}"
>
<slot></slot>
</select>
Expand Down Expand Up @@ -126,6 +133,9 @@ onMounted(() => {
});

function show() {
if (opening.value) return;
focus();

focused.value = true;
opening.value = true;

Expand Down
Loading

0 comments on commit 08a0368

Please sign in to comment.