fix(frontend): フォーカス/タブ移動に関する挙動を調整 (#226)

Cherry-pick commit e8c030673326871edf3623cf2b8675d68f9e1b13

Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com>
This commit is contained in:
kakkokari-gtyih 2024-07-09 18:55:22 +09:00
parent 5372b25940
commit d8cf64a2ca
23 changed files with 450 additions and 296 deletions

View file

@ -250,7 +250,6 @@ function onMousedown(evt: MouseEvent): void {
}
&:focus-visible {
outline: solid 2px var(--focus);
outline-offset: 2px;
}

View file

@ -87,17 +87,7 @@ async function onClick() {
}
&:focus-visible {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
outline-offset: 2px;
}
&:hover {

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
>
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
<MkMenu :items="items" :align="'left'" :returnFocusElement="returnFocusElement" @close="emit('closed')"/>
</div>
</Transition>
</template>
@ -28,6 +28,7 @@ import * as os from '@/os.js';
const props = defineProps<{
items: MenuItem[];
ev: MouseEvent;
returnFocusElement?: HTMLElement | null;
}>();
const emit = defineEmits<{

View file

@ -702,11 +702,6 @@ defineExpose({
border-radius: 4px;
font-size: 24px;
&:focus-visible {
outline: solid 2px var(--focus);
z-index: 1;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
}

View file

@ -185,17 +185,7 @@ onBeforeUnmount(() => {
}
&:focus-visible {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
outline-offset: 2px;
}
&:hover {

View file

@ -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" :width="imgWidth" :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>

View file

@ -39,23 +39,37 @@ SPDX-License-Identifier: AGPL-3.0-only
<audio
ref="audioEl"
preload="metadata"
@keydown.prevent="() => {}"
>
<source :src="audio.url">
</audio>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="togglePlayPause"
>
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
<i v-else class="ti ti-player-play-filled"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="showMenu">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="() => {}"
@mousedown.prevent.stop="showMenu"
>
<i class="ti ti-settings"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
<div :class="[$style.controlsChild, $style.controlsVolume]">
<button class="_button" :class="$style.controlButton" @click="toggleMute">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="toggleMute"
>
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
@ -64,6 +78,10 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="$style.volumeSeekbar"
/>
</div>
<MkMediaRange
v-model="volume"
:class="$style.volumeSeekbar"
/>
<MkMediaRange
v-model="rangePercent"
:class="$style.seekbarRoot"
@ -371,7 +389,7 @@ onDeactivated(() => {
border-radius: var(--radius);
overflow: clip;
&:focus {
&:focus-visible {
outline: none;
}
}
@ -437,6 +455,10 @@ onDeactivated(() => {
color: var(--accent);
background-color: var(--accentedBg);
}
&:focus-visible {
outline: none;
}
}
}

View file

@ -39,6 +39,7 @@ import XVideo from '@/components/MkMediaVideo.vue';
import * as os from '@/os.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { defaultStore } from '@/store.js';
import { focusParent } from '@/scripts/tms/focus.js';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
@ -49,7 +50,9 @@ const gallery = shallowRef<HTMLDivElement>();
const pswpZIndex = os.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) {
@ -60,7 +63,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 = '';
@ -131,6 +134,7 @@ onMounted(() => {
bgOpacity: 1,
showAnimationDuration: 100,
hideAnimationDuration: 100,
returnFocus: false,
pswpModule: PhotoSwipe,
});
@ -159,39 +163,47 @@ 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;
focusParent(activeEl, true, true);
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 => {
@ -199,6 +211,16 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
// FILE_TYPE_BROWSERSAFE
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
const openGallery = () => {
if (props.mediaList.filter(media => previewable(media)).length > 0) {
lightbox?.loadAndOpen(0);
}
};
defineExpose({
openGallery,
});
</script>
<style lang="scss" module>
@ -298,7 +320,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;
@ -312,7 +334,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;

View file

@ -481,7 +481,7 @@ onDeactivated(() => {
position: relative;
overflow: clip;
&:focus {
&:focus-visible {
outline: none;
}
}
@ -588,6 +588,10 @@ onDeactivated(() => {
border-radius: 99rem;
font-size: 1.1rem;
&:focus-visible {
outline: none;
}
}
.videoLoading {
@ -651,6 +655,10 @@ onDeactivated(() => {
&:hover {
background-color: var(--accent);
}
&:focus-visible {
outline: none;
}
}
}

View file

@ -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';
@ -19,7 +19,6 @@ const props = defineProps<{
targetElement: HTMLElement;
rootElement: HTMLElement;
width?: number;
viaKeyboard?: boolean;
}>();
const emit = defineEmits<{
@ -27,6 +26,8 @@ const emit = defineEmits<{
(ev: 'actioned'): void;
}>();
provide('isNestingMenu', true);
const el = shallowRef<HTMLElement>();
const align = 'left';

View file

@ -4,23 +4,33 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div role="menu">
<div role="menu" @focusin.passive.stop="() => {}">
<div
ref="itemsEl" v-hotkey="keymap"
ref="itemsEl"
v-hotkey="keymap"
tabindex="0"
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()"
:class="{
[$style.root]: true,
[$style.center]: align === 'center',
[$style.asDrawer]: asDrawer,
}"
:style="{
width: (width && !asDrawer) ? `${width}px` : '',
maxHeight: maxHeight ? `${maxHeight}px` : '',
}"
@keydown.stop="() => {}"
@contextmenu.self.prevent="() => {}"
>
<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" @mouseleave.passive="onItemMouseLeave">
<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">
@ -28,20 +38,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<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.passive="close(true)" @mouseenter.passive="onItemMouseEnter" @mouseleave.passive="onItemMouseLeave">
<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 }]" @click.prevent="item.active ? close(false) : clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter" @mouseleave.passive="onItemMouseLeave">
<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]" :disabled="unref(item.disabled)" @click.prevent="switchItem(item)" @mouseenter.passive="onItemMouseEnter" @mouseleave.passive="onItemMouseLeave">
<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">
@ -49,29 +59,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<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.active]: childShowingItem === item }]" :disabled="unref(item.disabled)" @mouseenter.prevent="preferClick ? null : showRadioOptions(item, $event)" @click.prevent="!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="menuitemradio" tabindex="0" :class="['_button', $style.item, $style.radio, { [$style.active]: unref(item.active) }]" @click.prevent="unref(item.active) ? null : clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter" @mouseleave.passive="onItemMouseLeave">
<div :class="$style.icon">
<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
<span :class="[$style.radioIcon, { [$style.radioChecked]: unref(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.active]: childShowingItem === item }]" @mouseenter.prevent="preferClick ? null : showChildren(item, $event)" @click.prevent="!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]: unref(item.active) }]" @click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter" @mouseleave.passive="onItemMouseLeave">
<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">
@ -80,25 +90,26 @@ 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="-1" :class="[$style.none, $style.item]">
<span>{{ i18n.ts.none }}</span>
</span>
</div>
<div v-if="childMenu">
<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/>
<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/>
</div>
</div>
</template>
<script lang="ts">
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { isTouchUsing } from '@/scripts/touch.js';
import { type Keymap } from '@/scripts/hotkey.js';
import { focusParent, isFocusable } from '@/scripts/tms/focus.js';
import { getNodeOrNull } from '@/scripts/tms/get-or-null.js';
const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
</script>
@ -108,11 +119,11 @@ const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue'));
const props = defineProps<{
items: MenuItem[];
viaKeyboard?: boolean;
asDrawer?: boolean;
align?: 'center' | string;
width?: number;
maxHeight?: number;
returnFocusElement?: HTMLElement | null;
}>();
const emit = defineEmits<{
@ -120,7 +131,9 @@ const emit = defineEmits<{
(ev: 'hide'): void;
}>();
const itemsEl = shallowRef<HTMLDivElement>();
const isNestingMenu = inject<boolean>('isNestingMenu', false);
const itemsEl = shallowRef<HTMLElement>();
const items2 = ref<InnerMenuItem[]>();
@ -177,21 +190,15 @@ function childActioned() {
close(true);
}
const onGlobalMousedown = (event: MouseEvent) => {
if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return;
if (child.value && child.value.checkHit(event)) return;
closeChild();
};
let childCloseTimer: null | number = null;
function onItemMouseEnter(item) {
function onItemMouseEnter() {
childCloseTimer = window.setTimeout(() => {
closeChild();
}, 300);
}
function onItemMouseLeave(item) {
function onItemMouseLeave() {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}
@ -210,7 +217,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
if (props.asDrawer) {
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
emit('close');
close(false);
});
emit('hide');
} else {
@ -237,7 +244,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
if (props.asDrawer) {
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
emit('close');
close(false);
});
emit('hide');
} else {
@ -256,15 +263,12 @@ function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
}
function close(actioned = false) {
disposeHandlers();
nextTick(() => {
closeChild();
emit('close', actioned);
}
function focusUp() {
focusPrev(document.activeElement);
}
function focusDown() {
focusNext(document.activeElement);
focusParent(props.returnFocusElement, true, false);
});
}
function switchItem(item: MenuSwitch & { ref: any }) {
@ -272,25 +276,75 @@ function switchItem(item: MenuSwitch & { ref: any }) {
item.ref = !item.ref;
}
function getValue<T>(item?: ComputedRef<T> | T) {
return isRef(item) ? item.value : item;
function focusUp() {
if (disposed) return;
if (!itemsEl.value?.contains(document.activeElement)) return;
const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1);
const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
targetElement.focus();
}
function focusDown() {
if (disposed) return;
if (!itemsEl.value?.contains(document.activeElement)) return;
const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0;
const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
targetElement.focus();
}
const onGlobalFocusin = (ev: FocusEvent) => {
if (disposed) return;
if (itemsEl.value?.parentElement?.contains(getNodeOrNull(ev.target))) return;
nextTick(() => {
if (itemsEl.value != null && isFocusable(itemsEl.value)) {
itemsEl.value.focus({ preventScroll: true });
nextTick(() => focusDown());
}
});
};
const onGlobalMousedown = (ev: MouseEvent) => {
if (disposed) return;
if (childTarget.value?.contains(getNodeOrNull(ev.target))) return;
if (child.value?.checkHit(ev)) return;
closeChild();
};
const setupHandlers = () => {
if (!isNestingMenu) {
document.addEventListener('focusin', onGlobalFocusin, { passive: true });
}
document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
};
let disposed = false;
const disposeHandlers = () => {
disposed = true;
if (!isNestingMenu) {
document.removeEventListener('focusin', onGlobalFocusin);
}
document.removeEventListener('mousedown', onGlobalMousedown);
};
onMounted(() => {
if (props.viaKeyboard) {
nextTick(() => {
if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false);
});
setupHandlers();
if (!isNestingMenu) {
nextTick(() => itemsEl.value?.focus({ preventScroll: true }));
}
// TODO:
//itemsEl.scrollTo();
document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGlobalMousedown);
disposeHandlers();
});
</script>
@ -303,6 +357,10 @@ onBeforeUnmount(() => {
overflow: auto;
overscroll-behavior: contain;
&:focus-visible {
outline: none;
}
&.center {
> .item {
text-align: center;
@ -320,7 +378,7 @@ onBeforeUnmount(() => {
font-size: 1em;
padding: 12px 24px;
&:before {
&::before {
width: calc(100% - 24px);
border-radius: 12px;
}
@ -350,8 +408,10 @@ onBeforeUnmount(() => {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none !important;
color: var(--menuFg, var(--fg));
&:before {
&::before {
content: "";
display: block;
position: absolute;
@ -365,56 +425,56 @@ onBeforeUnmount(() => {
border-radius: 6px;
}
&:not(:disabled):hover {
color: var(--accent);
text-decoration: none;
&:focus-visible {
outline: none;
&:before {
background: var(--accentedBg);
&:not(:hover):not(:active)::before {
outline: var(--focus) solid 2px;
outline-offset: -2px;
}
}
&:not(:disabled) {
&:hover,
&:focus-visible:active,
&:focus-visible.active {
color: var(--menuHoverFg, var(--accent));
&::before {
background-color: var(--menuHoverBg, var(--accentedBg));
}
}
&:not(:focus-visible):active,
&:not(:focus-visible).active {
color: var(--menuActiveFg, var(--fgOnAccent));
&::before {
background-color: var(--menuActiveBg, var(--accent));
}
}
}
&:disabled {
cursor: not-allowed;
}
&.danger {
color: #ff2a2a;
&:hover {
color: #fff;
&:before {
background: #ff4242;
}
--menuFg: #ff2a2a;
--menuHoverFg: #fff;
--menuHoverBg: #ff4242;
--menuActiveFg: #fff;
--menuActiveBg: #d42e2e;
}
&:active {
color: #fff;
&:before {
background: #d42e2e !important;
}
}
&.radio {
--menuActiveFg: var(--accent);
--menuActiveBg: var(--accentedBg);
}
&:active,
&.active {
color: var(--fgOnAccent) !important;
opacity: 1;
&:before {
background: var(--accent) !important;
}
}
&.radioActive {
color: var(--accent) !important;
opacity: 1;
&:before {
background-color: var(--accentedBg) !important;
}
}
&:not(:active):focus-visible {
box-shadow: 0 0 0 2px var(--focus) inset;
&.parent {
--menuActiveFg: var(--accent);
--menuActiveBg: var(--accentedBg);
}
&.label {
@ -432,22 +492,6 @@ onBeforeUnmount(() => {
pointer-events: none;
opacity: 0.7;
}
&.parent {
pointer-events: auto;
display: flex;
align-items: center;
cursor: default;
&.childShowing {
color: var(--accent);
text-decoration: none;
&:before {
background: var(--accentedBg);
}
}
}
}
.item_content {
@ -466,18 +510,6 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.switch {
position: relative;
display: flex;
transition: all 0.2s ease;
user-select: none;
cursor: pointer;
}
.switchDisabled {
cursor: not-allowed;
}
.switchButton {
margin-left: -2px;
--height: 1.35em;
@ -489,14 +521,6 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
}
.switchInput {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
.icon {
margin-right: 8px;
line-height: 1;
@ -525,12 +549,12 @@ onBeforeUnmount(() => {
border-top: solid 0.5px var(--divider);
}
.radio {
.radioIcon {
display: inline-block;
position: relative;
width: 1em;
height: 1em;
vertical-align: -.125em;
vertical-align: -0.125em;
border-radius: 50%;
border: solid 2px var(--divider);
background-color: var(--panel);

View file

@ -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>
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</I18n>
<div :class="$style.renoteInfo">
<button ref="renoteTime" :class="$style.renoteTime" class="_button" @click="showRenoteMenu()">
<button ref="renoteTime" :class="$style.renoteTime" class="_button" @mousedown.prevent="showRenoteMenu()">
<i class="ti ti-dots" :class="$style.renoteMenu"></i>
<MkTime :time="note.createdAt"/>
</button>
@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<div v-if="isEnabledUrlPreview">
@ -110,7 +110,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="renoteButton"
:class="$style.footerButton"
class="_button"
@mousedown="renote()"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
@ -125,10 +125,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()">
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -175,7 +175,6 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
@ -199,6 +198,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { type Keymap } from '@/scripts/hotkey.js';
import { focusPrev, focusNext } from '@/scripts/tms/focus.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -257,6 +257,7 @@ const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
@ -419,7 +420,7 @@ function renote(viaKeyboard = false) {
});
}
function reply(viaKeyboard = false): void {
function reply(): void {
pleaseLogin();
if (props.mock) {
return;
@ -427,13 +428,12 @@ function reply(viaKeyboard = false): void {
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
animation: !viaKeyboard,
}).then(() => {
focus();
});
}
function react(viaKeyboard = false): void {
function react(): void {
pleaseLogin();
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
@ -528,18 +528,16 @@ function onContextmenu(ev: MouseEvent): void {
}
}
function showMenu(viaKeyboard = false): void {
function showMenu(): void {
if (props.mock) {
return;
}
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
}).then(focus).finally(cleanup);
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip() {
async function clip(): Promise<void> {
if (props.mock) {
return;
}
@ -547,7 +545,7 @@ async function clip() {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
function showRenoteMenu(): void {
if (props.mock) {
return;
}
@ -572,18 +570,14 @@ function showRenoteMenu(viaKeyboard = false): void {
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getUnrenote(),
], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
], renoteTime.value);
} else {
os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
], renoteTime.value);
}
}
@ -596,11 +590,11 @@ function blur() {
}
function focusBefore() {
focusPrev(rootEl.value ?? null);
focusPrev(rootEl.value);
}
function focusAfter() {
focusNext(rootEl.value ?? null);
focusNext(rootEl.value);
}
function readPromo() {
@ -638,7 +632,7 @@ function emitUpdReaction(emoji: string, delta: number) {
&:focus-visible {
outline: none;
&:after {
&::after {
content: "";
pointer-events: none;
display: block;
@ -651,7 +645,7 @@ function emitUpdReaction(emoji: string, delta: number) {
margin: auto;
width: calc(100% - 8px);
height: calc(100% - 8px);
border: dashed 1px var(--focus);
border: dashed 2px var(--focus);
border-radius: var(--radius);
box-sizing: border-box;
}

View file

@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="rootEl"
v-hotkey="keymap"
:class="$style.root"
:tabindex="isDeleted ? '-1' : '0'"
>
<div v-if="appearNote.reply && appearNote.reply.replyId">
<div v-if="!conversationLoaded" style="padding: 16px">
@ -31,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</I18n>
</span>
<div :class="$style.renoteInfo">
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()">
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @mousedown.prevent="showRenoteMenu()">
<i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i>
<MkTime :time="note.createdAt"/>
</button>
@ -92,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<div v-if="isEnabledUrlPreview">
@ -118,7 +119,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="renoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown="renote()"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
@ -133,10 +134,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()">
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -281,6 +282,7 @@ const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false);
const isDeleted = ref(false);
@ -303,6 +305,7 @@ const keymap = {
if (!defaultStore.state.showClipButtonInNoteFooter) return;
clip();
},
'o': () => galleryEl.value?.openGallery(),
'v|enter': () => {
if (appearNote.value.cw != null) {
showContent.value = !showContent.value;
@ -392,29 +395,26 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
});
}
function renote(viaKeyboard = false) {
async function renote() {
pleaseLogin();
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
os.popupMenu(menu, renoteButton.value, {
viaKeyboard,
});
const { menu } = await getRenoteMenu({ note: note.value, renoteButton, canRenote: canRenote.value });
os.popupMenu(menu, renoteButton.value);
}
function reply(viaKeyboard = false): void {
function reply(): void {
pleaseLogin();
showMovedDialog();
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
animation: !viaKeyboard,
}).then(() => {
focus();
});
}
function react(viaKeyboard = false): void {
function react(): void {
pleaseLogin();
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
@ -424,7 +424,7 @@ function react(viaKeyboard = false): void {
noteId: appearNote.value.id,
reaction: '❤️',
});
const el = reactButton.value as HTMLElement | null | undefined;
const el = reactButton.value;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
@ -488,18 +488,16 @@ function onContextmenu(ev: MouseEvent): void {
}
}
function showMenu(viaKeyboard = false): void {
function showMenu(): void {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
}).then(focus).finally(cleanup);
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip() {
async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
function showRenoteMenu(): void {
if (!isMyRenote) return;
pleaseLogin();
os.popupMenu([{
@ -512,9 +510,7 @@ function showRenoteMenu(viaKeyboard = false): void {
});
isDeleted.value = true;
},
}], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
}], renoteTime.value);
}
function focus() {
@ -556,6 +552,28 @@ function loadConversation() {
transition: box-shadow 0.1s ease;
overflow: clip;
contain: content;
&:focus-visible {
outline: none;
&::after {
content: "";
pointer-events: none;
display: block;
position: absolute;
z-index: 10;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: calc(100% - 8px);
height: calc(100% - 8px);
border: dashed 2px var(--focus);
border-radius: var(--radius);
box-sizing: border-box;
}
}
}
.replyTo {

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" @click="click" @close="onModalClose" @closed="onModalClosed">
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusElement="returnFocusElement" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
</MkModal>
</template>
@ -19,8 +19,8 @@ defineProps<{
items: MenuItem[];
align?: 'center' | string;
width?: number;
viaKeyboard?: boolean;
src?: any;
returnFocusElement?: HTMLElement | null;
}>();
const emit = defineEmits<{

View file

@ -6,20 +6,29 @@ 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"
tabindex="0"
:class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused || opening }]"
@focus="focused = true"
@blur="focused = false"
@mousedown.prevent="show"
@keydown.space.enter="show"
>
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
<select
ref="inputEl"
v-model="v"
v-adaptive-border
tabindex="-1"
:class="$style.inputCore"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
@focus="focused = true"
@blur="focused = false"
@input="onInput"
@mousedown.prevent="() => {}"
@keydown.prevent="() => {}"
>
<slot></slot>
</select>
@ -75,7 +84,7 @@ const height =
props.large ? 39 :
36;
const focus = () => inputEl.value?.focus();
const focus = () => container.value?.focus();
const onInput = (ev) => {
changed.value = true;
};
@ -126,7 +135,9 @@ onMounted(() => {
});
function show() {
focused.value = true;
if (opening.value) return;
focus();
opening.value = true;
const menu: MenuItem[] = [];
@ -173,8 +184,6 @@ function show() {
onClosing: () => {
opening.value = false;
},
}).then(() => {
focused.value = false;
});
}
</script>
@ -225,6 +234,10 @@ function show() {
}
}
&:focus {
outline: none;
}
&:hover {
> .inputCore {
border-color: var(--inputBorderHover) !important;

View file

@ -5,7 +5,7 @@
// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
import { Component, markRaw, Ref, ref, defineAsyncComponent, nextTick } from 'vue';
import { EventEmitter } from 'eventemitter3';
import * as Misskey from 'misskey-js';
import type { ComponentProps as CP } from 'vue-component-type-helpers';
@ -24,6 +24,7 @@ import MkContextMenu from '@/components/MkContextMenu.vue';
import { MenuItem } from '@/types/menu.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { getHTMLElementOrNull } from '@/scripts/tms/get-or-null.js';
export const openingWindowsCount = ref(0);
@ -622,46 +623,49 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: {
align?: string;
width?: number;
viaKeyboard?: boolean;
onClosing?: () => void;
}): Promise<void> {
return new Promise(resolve => {
let returnFocusElement = getHTMLElementOrNull(src) ?? getHTMLElementOrNull(document.activeElement);
return new Promise(resolve => nextTick(() => {
const { dispose } = popup(MkPopupMenu, {
items,
src,
width: options?.width,
align: options?.align,
viaKeyboard: options?.viaKeyboard,
returnFocusElement,
}, {
closed: () => {
resolve();
dispose();
returnFocusElement = null;
},
closing: () => {
if (options?.onClosing) options.onClosing();
options?.onClosing?.();
},
});
});
}));
}
export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
let returnFocusElement = getHTMLElementOrNull(ev.currentTarget ?? ev.target) ?? getHTMLElementOrNull(document.activeElement);
ev.preventDefault();
return new Promise(resolve => {
return new Promise(resolve => nextTick(() => {
const { dispose } = popup(MkContextMenu, {
items,
ev,
returnFocusElement,
}, {
closed: () => {
resolve();
dispose();
dispose?.();
returnFocusElement = null;
},
});
});
}));
}
export function post(props: Record<string, any> = {}): Promise<void> {
showMovedDialog();
return new Promise(resolve => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
@ -671,7 +675,7 @@ export function post(props: Record<string, any> = {}): Promise<void> {
const { dispose } = popup(MkPostFormDialog, props, {
closed: () => {
resolve();
dispose();
dispose?.();
},
});
});

View file

@ -234,6 +234,7 @@ onMounted(async () => {
background-color: var(--accentedBg);
color: var(--accent);
text-decoration: none;
outline: none;
}
&.danger {

View file

@ -286,6 +286,7 @@ definePageMetadata(() => ({
background-color: var(--accentedBg);
color: var(--accent);
text-decoration: none;
outline: none;
}
}

View file

@ -342,6 +342,7 @@ definePageMetadata(() => ({
&:hover, &:focus {
opacity: .7;
}
&:active {
cursor: pointer;
}

View file

@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function focusPrev(el: Element | null, self = false, scroll = true) {
if (el == null) return;
if (!self) el = el.previousElementSibling;
if (el) {
if (el.hasAttribute('tabindex')) {
(el as HTMLElement).focus({
preventScroll: !scroll,
});
} else {
focusPrev(el.previousElementSibling, true);
}
}
}
export function focusNext(el: Element | null, self = false, scroll = true) {
if (el == null) return;
if (!self) el = el.nextElementSibling;
if (el) {
if (el.hasAttribute('tabindex')) {
(el as HTMLElement).focus({
preventScroll: !scroll,
});
} else {
focusPrev(el.nextElementSibling, true);
}
}
}

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getElementOrNull, getNodeOrNull } from '@/scripts/tms/get-or-null.js';
type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement;
export const isFocusable = (input: MaybeHTMLElement | null | undefined): input is HTMLElement => {
if (input == null || !(input instanceof HTMLElement)) return false;
if (input.tabIndex < 0) return false;
if ('disabled' in input && input.disabled === true) return false;
if ('readonly' in input && input.readonly === true) return false;
if (!input.ownerDocument.contains(input)) return false;
const style = window.getComputedStyle(input);
if (style.display === 'none') return false;
if (style.visibility === 'hidden') return false;
if (style.opacity === '0') return false;
if (style.pointerEvents === 'none') return false;
return true;
};
export const focusPrev = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
const element = self ? input : getElementOrNull(input)?.previousElementSibling;
if (element == null) return;
if (isFocusable(element)) {
focusOrScroll(element, scroll);
} else {
focusPrev(element, false, scroll);
}
};
export const focusNext = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
const element = self ? input : getElementOrNull(input)?.nextElementSibling;
if (element == null) return;
if (isFocusable(element)) {
focusOrScroll(element, scroll);
} else {
focusNext(element, false, scroll);
}
};
export const focusParent = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => {
const element = self ? input : getNodeOrNull(input)?.parentElement;
if (element == null) return;
if (isFocusable(element)) {
focusOrScroll(element, scroll);
} else {
focusParent(element, false, scroll);
}
};
const focusOrScroll = (element: HTMLElement, scroll: boolean) => {
if (document.activeElement === element) {
if (scroll) {
element.scrollIntoView({
behavior: 'instant',
block: 'nearest',
inline: 'nearest',
});
}
} else {
element.focus({ preventScroll: !scroll });
}
};

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const getNodeOrNull = (input: unknown): Node | null => {
if (input instanceof Node) return input;
return null;
};
export const getElementOrNull = (input: unknown): Element | null => {
if (input instanceof Element) return input;
return null;
};
export const getHTMLElementOrNull = (input: unknown): HTMLElement | null => {
if (input instanceof HTMLElement) return input;
return null;
};

View file

@ -113,6 +113,10 @@ a {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
&:focus-visible {
outline-offset: 2px;
}
&:hover {
text-decoration: underline;
}
@ -143,6 +147,15 @@ rt {
white-space: initial;
}
:focus-visible {
outline: var(--focus) solid 2px;
outline-offset: -2px;
&:hover {
text-decoration: none;
}
}
.ti {
width: 1.28em;
vertical-align: -12%;
@ -230,10 +243,6 @@ rt {
line-height: inherit;
max-width: 100%;
&:focus-visible {
outline: none;
}
&:disabled {
opacity: 0.5;
cursor: default;
@ -270,13 +279,17 @@ rt {
._help {
color: var(--accent);
cursor: help
cursor: help;
}
._textButton {
@extend ._button;
color: var(--accent);
&:focus-visible {
outline-offset: 2px;
}
&:not(:disabled):hover {
text-decoration: underline;
}