diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ceb8634a..e1fbea0c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +### Note +- デッキUIの新着ノートをサウンドで通知する機能の追加(v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。 + ### General - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 @@ -9,6 +12,9 @@ - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 - 埋め込みコードやウェブサイトへの実装方法の詳細はMisskey Hubに掲載予定です - Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善 +- Enhance: 非ログイン時のハイライトTLのデザインを改善 +- Enhance: フロントエンドのアクセシビリティ改善 + (Based on https://github.com/taiyme/misskey/pull/226) - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正 - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968) - Fix: リバーシの対局を正しく共有できないことがある問題を修正 diff --git a/locales/index.d.ts b/locales/index.d.ts index 36818b0065..e37bbd23f6 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -7527,14 +7527,6 @@ export interface Locale extends ILocale { * 通知 */ "notification": string; - /** - * アンテナ受信 - */ - "antenna": string; - /** - * チャンネル通知 - */ - "channel": string; /** * リアクション選択時 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a909a3df1a..fe764972d1 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1974,8 +1974,6 @@ _sfx: note: "ノート" noteMy: "ノート(自分)" notification: "通知" - antenna: "アンテナ受信" - channel: "チャンネル通知" reaction: "リアクション選択時" _soundSettings: diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index 5d103fa789..c8134416b5 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -153,7 +153,7 @@ onMounted(() => { background: linear-gradient(0deg, #ffee20, #eb7018); } - &:before { + &::before { content: ""; display: block; position: absolute; @@ -173,7 +173,7 @@ onMounted(() => { background: linear-gradient(0deg, #e1e1e1, #7c7c7c); } - &:before { + &::before { content: ""; display: block; position: absolute; diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 25b003ba5a..9560efb7d9 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -250,7 +250,6 @@ function onMousedown(evt: MouseEvent): void { } &:focus-visible { - outline: solid 2px var(--focus); outline-offset: 2px; } diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 841d37a568..35dc3ad4bf 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -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 { diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index 4ff64dc4ba..c30cb66c07 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div style="position: relative;"> - <MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt"> + <MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" @click="updateLastReadedAt"> <div class="banner" :style="bannerStyle"> <div class="fade"></div> <div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div> @@ -80,6 +80,7 @@ const bannerStyle = computed(() => { <style lang="scss" scoped> .eftoefju { display: block; + position: relative; overflow: hidden; width: 100%; @@ -87,6 +88,22 @@ const bannerStyle = computed(() => { text-decoration: none; } + &:focus-within { + outline: none; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: inherit; + pointer-events: none; + box-shadow: inset 0 0 0 2px var(--focus); + } + } + > .banner { position: relative; width: 100%; diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue index 6299a28e9f..2e9a172c23 100644 --- a/packages/frontend/src/components/MkClipPreview.vue +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -40,6 +40,14 @@ const remaining = computed(() => { .link { display: block; + &:focus-visible { + outline: none; + + .root { + box-shadow: inset 0 0 0 2px var(--focus); + } + } + &:hover { text-decoration: none; color: var(--accent); diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index a807742bb9..8ea8fa6cf3 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -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'" @close="emit('closed')"/> </div> </Transition> </template> diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index a2cb3185f4..b5f6e78b6c 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -45,11 +45,11 @@ function toggle() { .label { margin-left: 4px; - &:before { + &::before { content: '('; } - &:after { + &::after { content: ')'; } } diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index c52404a319..5c3c6aa51d 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')"> +<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')" @esc="cancel()"> <div :class="$style.root"> <div v-if="icon" :class="$style.icon"> <i :class="icon"></i> @@ -51,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue'; +import { ref, shallowRef, computed } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -156,10 +156,6 @@ function onBgClick() { if (props.cancelableByBgClick) cancel(); } */ -function onKeydown(evt: KeyboardEvent) { - if (evt.key === 'Escape') cancel(); -} - function onInputKeydown(evt: KeyboardEvent) { if (evt.key === 'Enter' && okButtonDisabledReason.value === null) { evt.preventDefault(); @@ -167,14 +163,6 @@ function onInputKeydown(evt: KeyboardEvent) { ok(); } } - -onMounted(() => { - document.addEventListener('keydown', onKeydown); -}); - -onBeforeUnmount(() => { - document.removeEventListener('keydown', onKeydown); -}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 4106b0a436..90284890a5 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -115,14 +115,14 @@ function onDragend() { background: rgba(#000, 0.05); > .label { - &:before, - &:after { + &::before, + &::after { background: #0b65a5; } &.red { - &:before, - &:after { + &::before, + &::after { background: #c12113; } } @@ -133,14 +133,14 @@ function onDragend() { background: rgba(#000, 0.1); > .label { - &:before, - &:after { + &::before, + &::after { background: #0b588c; } &.red { - &:before, - &:after { + &::before, + &::after { background: #ce2212; } } @@ -159,8 +159,8 @@ function onDragend() { } > .label { - &:before, - &:after { + &::before, + &::after { display: none; } } @@ -181,8 +181,8 @@ function onDragend() { left: 0; pointer-events: none; - &:before, - &:after { + &::before, + &::after { content: ""; display: block; position: absolute; @@ -190,14 +190,14 @@ function onDragend() { background: #0c7ac9; } - &:before { + &::before { top: 0; left: 57px; width: 28px; height: 8px; } - &:after { + &::after { top: 57px; left: 0; width: 8px; @@ -205,8 +205,8 @@ function onDragend() { } &.red { - &:before, - &:after { + &::before, + &::after { background: #c12113; } } diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 1cc8b15b73..1790e57c24 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -296,7 +296,7 @@ function onContextmenu(ev: MouseEvent) { cursor: pointer; &.draghover { - &:after { + &::after { content: ""; pointer-events: none; position: absolute; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 4bd4bee1e5..4a3ed69f47 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -5,7 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"> - <input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter"> + <input + ref="searchEl" + :value="q" + class="search" + data-prevent-emoji-insert + :class="{ filled: q != null && q != '' }" + :placeholder="i18n.ts.search" + type="search" + autocapitalize="off" + @input="input()" + @paste.stop="paste" + @keydown="onKeydown" + > <!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 --> <div ref="emojisEl" class="emojis" tabindex="-1"> <section class="result"> @@ -139,6 +151,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: string): void; + (ev: 'esc'): void; }>(); const searchEl = shallowRef<HTMLInputElement>(); @@ -433,9 +446,18 @@ function paste(event: ClipboardEvent): void { } } -function onEnter(ev: KeyboardEvent) { +function onKeydown(ev: KeyboardEvent) { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; - done(); + if (ev.key === 'Enter') { + ev.preventDefault(); + ev.stopPropagation(); + done(); + } + if (ev.key === 'Escape') { + ev.preventDefault(); + ev.stopPropagation(); + emit('esc'); + } } function done(query?: string): boolean | void { @@ -702,11 +724,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); } diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index adcea839ee..a413b146ba 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only :manualShowing="manualShowing" :src="src" @click="modal?.close()" + @esc="modal?.close()" @opening="opening" @close="emit('close')" @closed="emit('closed')" @@ -28,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only :asDrawer="type === 'drawer'" :max-height="maxHeight" @chosen="chosen" + @esc="modal?.close()" /> </MkModal> </template> diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index c5dd877971..6783804cc5 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel" tabindex="-1"> +<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel"> <article> <header> <h1 :title="flash.title">{{ flash.title }}</h1> @@ -39,6 +39,10 @@ const props = defineProps<{ color: var(--accent); } + &:focus-visible { + outline-offset: -2px; + } + > article { padding: 16px; diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 9b8eb19a11..f805be7b57 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -7,10 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened"> <MkStickyContainer> <template #header> - <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle"> + <button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle"> <div :class="$style.headerIcon"><slot name="icon"></slot></div> <div :class="$style.headerText"> - <div> + <div :class="$style.headerTextMain"> <MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine> </div> <div :class="$style.headerTextSub"> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-if="opened" class="ti ti-chevron-up icon"></i> <i v-else class="ti ti-chevron-down icon"></i> </div> - </div> + </button> </template> <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened"> @@ -147,6 +147,10 @@ onMounted(() => { background: var(--buttonHoverBg); } + &:focus-within { + outline-offset: 2px; + } + &.active { color: var(--accent); background: var(--buttonHoverBg); @@ -190,6 +194,12 @@ onMounted(() => { padding-right: 12px; } +.headerTextMain, +.headerTextSub { + width: fit-content; + max-width: 100%; +} + .headerTextSub { color: var(--fgTransparentWeak); font-size: .85em; diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index 6a4081079c..ea76950c0d 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -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 { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 47cccd9b7c..2bb5b8762a 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -83,7 +83,7 @@ function leaveHover(): void { > article { > footer { - &:before { + &::before { opacity: 1; } } @@ -139,7 +139,7 @@ function leaveHover(): void { text-shadow: 0 0 8px #000; background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); - &:before { + &::before { content: ""; display: block; position: absolute; diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 4e3fafe845..617404f5c4 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -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> diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index f9d4334c4c..8e3c19bd12 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()"> <div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }"> <div class="main"> <template v-for="item in items" :key="item.text"> diff --git a/packages/frontend/src/components/MkMediaAudio.vue b/packages/frontend/src/components/MkMediaAudio.vue index 366d6f7b81..6ce86b0e8e 100644 --- a/packages/frontend/src/components/MkMediaAudio.vue +++ b/packages/frontend/src/components/MkMediaAudio.vue @@ -45,19 +45,32 @@ SPDX-License-Identifier: AGPL-3.0-only <source :src="audio.url" :type="audio.type"> </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> @@ -380,7 +393,7 @@ onDeactivated(() => { border-radius: var(--radius); overflow: clip; - &:focus { + &:focus-visible { outline: none; } } @@ -446,6 +459,10 @@ onDeactivated(() => { color: var(--accent); background-color: var(--accentedBg); } + + &:focus-visible { + outline: none; + } } } diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index f87a0d49ac..53ba7c656e 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -43,6 +43,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/focus.js'; const props = defineProps<{ mediaList: Misskey.entities.DriveFile[]; @@ -58,7 +59,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) { @@ -69,7 +72,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 = ''; @@ -141,6 +144,7 @@ onMounted(() => { bgOpacity: 1, showAnimationDuration: 100, hideAnimationDuration: 100, + returnFocus: false, pswpModule: PhotoSwipe, }); @@ -169,39 +173,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 => { @@ -209,6 +221,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> @@ -328,7 +350,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; @@ -342,7 +364,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; diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 7bf1bd596b..e427f50924 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -489,7 +489,7 @@ onDeactivated(() => { position: relative; overflow: clip; - &:focus { + &:focus-visible { outline: none; } } @@ -596,6 +596,10 @@ onDeactivated(() => { border-radius: 99rem; font-size: 1.1rem; + + &:focus-visible { + outline: none; + } } .videoLoading { @@ -659,6 +663,10 @@ onDeactivated(() => { &:hover { background-color: var(--accent); } + + &:focus-visible { + outline: none; + } } } diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue index dfb6d34618..235790556c 100644 --- a/packages/frontend/src/components/MkMenu.child.vue +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -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'; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 119504f744..68479989b2 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -4,23 +4,42 @@ 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 +47,48 @@ 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 +96,61 @@ 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)" + @keydown.enter.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)" + @keydown.enter.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 +159,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 { isFocusable } from '@/scripts/focus.js'; +import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; const childrenCache = new WeakMap<MenuParent, MenuItem[]>(); </script> @@ -108,7 +188,6 @@ const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue')); const props = defineProps<{ items: MenuItem[]; - viaKeyboard?: boolean; asDrawer?: boolean; align?: 'center' | string; width?: number; @@ -120,7 +199,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,25 +258,19 @@ 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); } -async function showRadioOptions(item: MenuRadio, ev: MouseEvent) { +async function showRadioOptions(item: MenuRadio, ev: Event) { const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => { const value = item.options[key]; return { @@ -210,7 +285,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 { @@ -220,7 +295,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) { } } -async function showChildren(item: MenuParent, ev: MouseEvent) { +async function showChildren(item: MenuParent, ev: Event) { const children: MenuItem[] = await (async () => { if (childrenCache.has(item)) { return childrenCache.get(item)!; @@ -237,7 +312,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 +331,11 @@ function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) { } function close(actioned = false) { - emit('close', actioned); -} - -function focusUp() { - focusPrev(document.activeElement); -} - -function focusDown() { - focusNext(document.activeElement); + disposeHandlers(); + nextTick(() => { + closeChild(); + emit('close', actioned); + }); } function switchItem(item: MenuSwitch & { ref: any }) { @@ -272,25 +343,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(); } -onMounted(() => { - if (props.viaKeyboard) { - nextTick(() => { - if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false); - }); +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 }); } - - // TODO: アクティブな要素までスクロール - //itemsEl.scrollTo(); - document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); +}; + +let disposed = false; + +const disposeHandlers = () => { + disposed = true; + if (!isNestingMenu) { + document.removeEventListener('focusin', onGlobalFocusin); + } + document.removeEventListener('mousedown', onGlobalMousedown); +}; + +onMounted(() => { + setupHandlers(); + + if (!isNestingMenu) { + nextTick(() => itemsEl.value?.focus({ preventScroll: true })); + } }); onBeforeUnmount(() => { - document.removeEventListener('mousedown', onGlobalMousedown); + disposeHandlers(); }); </script> @@ -303,6 +424,10 @@ onBeforeUnmount(() => { overflow: auto; overscroll-behavior: contain; + &:focus-visible { + outline: none; + } + &.center { > .item { text-align: center; @@ -320,7 +445,7 @@ onBeforeUnmount(() => { font-size: 1em; padding: 12px 24px; - &:before { + &::before { width: calc(100% - 24px); border-radius: 12px; } @@ -350,8 +475,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 +492,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; - } - } - - &:active { - color: #fff; - - &:before { - background: #d42e2e !important; - } - } + --menuFg: #ff2a2a; + --menuHoverFg: #fff; + --menuHoverBg: #ff4242; + --menuActiveFg: #fff; + --menuActiveBg: #d42e2e; } - &:active, - &.active { - color: var(--fgOnAccent) !important; - opacity: 1; - - &:before { - background: var(--accent) !important; - } + &.radio { + --menuActiveFg: var(--accent); + --menuActiveBg: var(--accentedBg); } - &.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 +559,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 +577,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 +588,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 +616,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); diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 264d8b6c9c..a5fbf8d365 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -30,9 +30,9 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.transition_modal_leaveTo]: transitionName === 'modal', [$style.transition_send_leaveTo]: transitionName === 'send', })" - :duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened" + :duration="transitionDuration" appear @afterLeave="onClosed" @enter="emit('opening')" @afterEnter="onOpened" > - <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> + <div v-show="manualShowing != null ? manualShowing : showing" ref="modalRootEl" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> <div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div> <div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick"> <slot :max-height="maxHeight" :type="type"></slot> @@ -48,6 +48,8 @@ import { isTouchUsing } from '@/scripts/touch.js'; import { defaultStore } from '@/store.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { type Keymap } from '@/scripts/hotkey.js'; +import { focusTrap } from '@/scripts/focus-trap.js'; +import { focusParent } from '@/scripts/focus.js'; function getFixedContainer(el: Element | null): Element | null { if (el == null || el.tagName === 'BODY') return null; @@ -69,6 +71,7 @@ const props = withDefaults(defineProps<{ zPriority?: 'low' | 'middle' | 'high'; noOverlap?: boolean; transparentBg?: boolean; + returnFocusTo?: HTMLElement | null; }>(), { manualShowing: null, src: null, @@ -77,6 +80,7 @@ const props = withDefaults(defineProps<{ zPriority: 'low', noOverlap: true, transparentBg: false, + returnFocusTo: null, }); const emit = defineEmits<{ @@ -94,6 +98,7 @@ const maxHeight = ref<number>(); const fixed = ref(false); const transformOrigin = ref('center'); const showing = ref(true); +const modalRootEl = shallowRef<HTMLElement>(); const content = shallowRef<HTMLElement>(); const zIndex = os.claimZIndex(props.zPriority); const useSendAnime = ref(false); @@ -132,6 +137,7 @@ const transitionDuration = computed((() => : 0 )); +let releaseFocusTrap: (() => void) | null = null; let contentClicking = false; function close(opts: { useSendAnimation?: boolean } = {}) { @@ -296,6 +302,10 @@ const onOpened = () => { }, { passive: true }); }; +const onClosed = () => { + emit('closed'); +}; + const alignObserver = new ResizeObserver((entries, observer) => { align(); }); @@ -313,6 +323,20 @@ onMounted(() => { align(); }, { immediate: true }); + watch([showing, () => props.manualShowing], ([showing, manualShowing]) => { + if (manualShowing === true || (manualShowing == null && showing === true)) { + if (modalRootEl.value != null) { + const { release } = focusTrap(modalRootEl.value); + + releaseFocusTrap = release; + modalRootEl.value.focus(); + } + } else { + releaseFocusTrap?.(); + focusParent(props.returnFocusTo ?? props.src, true, false); + } + }, { immediate: true }); + nextTick(() => { alignObserver.observe(content.value!); }); diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index d3657afa94..78053c8cfd 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')"> - <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown"> +<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')"> + <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }"> <div ref="headerEl" :class="$style.header"> <button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> <span :class="$style.title"> @@ -42,6 +42,7 @@ const emit = defineEmits<{ (event: 'close'): void; (event: 'closed'): void; (event: 'ok'): void; + (event: 'esc'): void; }>(); const modal = shallowRef<InstanceType<typeof MkModal>>(); @@ -58,14 +59,6 @@ const onBgClick = () => { emit('click'); }; -const onKeydown = (evt) => { - if (evt.which === 27) { // Esc - evt.preventDefault(); - evt.stopPropagation(); - close(); - } -}; - const ro = new ResizeObserver((entries, observer) => { if (rootEl.value == null || headerEl.value == null) return; bodyWidth.value = rootEl.value.offsetWidth; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 905a76e2a0..f14f5c665c 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -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" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/> + <MkMediaList ref="galleryEl" :mediaList="appearNote.files" :originalEntityUrl="`${url}/notes/${appearNote.id}`"/> </div> <MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/> <div v-if="isEnabledUrlPreview && !inEmbedPage"> @@ -128,7 +128,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> @@ -143,10 +143,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> @@ -193,7 +193,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'; @@ -218,6 +217,7 @@ import { shouldCollapsed } from '@/scripts/collapsed.js'; import { isEnabledUrlPreview } from '@/instance.js'; import { url } from '@/config.js'; import { type Keymap } from '@/scripts/hotkey.js'; +import { focusPrev, focusNext } from '@/scripts/focus.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -277,6 +277,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); @@ -338,7 +339,7 @@ const keymap = { }, 'o': () => { if (renoteCollapsed.value) return; - showMenu(); + galleryEl.value?.openGallery(); }, 'v|enter': () => { if (renoteCollapsed.value) { @@ -439,7 +440,7 @@ function renote(viaKeyboard = false) { }); } -function reply(viaKeyboard = false): void { +function reply(): void { pleaseLogin(); if (props.mock) { return; @@ -447,13 +448,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') { @@ -548,18 +548,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; } @@ -567,7 +565,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; } @@ -592,18 +590,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); } } @@ -616,11 +610,11 @@ function blur() { } function focusBefore() { - focusPrev(rootEl.value ?? null); + focusPrev(rootEl.value); } function focusAfter() { - focusNext(rootEl.value ?? null); + focusNext(rootEl.value); } function readPromo() { @@ -658,7 +652,7 @@ function emitUpdReaction(emoji: string, delta: number) { &:focus-visible { outline: none; - &:after { + &::after { content: ""; pointer-events: none; display: block; @@ -671,7 +665,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; } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 8f65e3b60a..a8fed56c39 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -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) { +function renote() { pleaseLogin(); showMovedDialog(); const { menu } = getRenoteMenu({ note: note.value, renoteButton }); - os.popupMenu(menu, renoteButton.value, { - viaKeyboard, - }); + 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 { diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index cc2f770cda..c4479bb0d6 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> - <MkAvatar :class="$style.avatar" :user="user" link preview/> + <MkAvatar :class="$style.avatar" :user="user"/> <div :class="$style.main"> <div :class="$style.header"> <MkUserName :user="user" :nowrap="true"/> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 3fa2eb254e..ee65743574 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -343,7 +343,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) margin-right: 4px; position: relative; - &:before { + &::before { position: absolute; transform: rotate(180deg); } diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue index f6dc00698c..8559d4b96e 100644 --- a/packages/frontend/src/components/MkPagePreview.vue +++ b/packages/frontend/src/components/MkPagePreview.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1"> +<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj"> <div v-if="page.eyeCatchingImage" class="thumbnail"> <MediaImage :image="page.eyeCatchingImage" @@ -50,12 +50,29 @@ const props = defineProps<{ <style lang="scss" scoped> .vhpxefrj { display: block; + position: relative; &:hover { text-decoration: none; color: var(--accent); } + &:focus-within { + outline: none; + + &::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--radius); + pointer-events: none; + box-shadow: inset 0 0 0 2px var(--focus); + } + } + > .thumbnail { & + article { border-radius: 0 0 var(--radius) var(--radius); diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index be0b07612a..8a0c7b1e54 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -4,8 +4,8 @@ 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"/> +<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed"> + <MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :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; + returnFocusTo?: HTMLElement | null; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 0dc1aa0891..d057d197ec 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -570,6 +570,7 @@ function clear() { function onKeydown(ev: KeyboardEvent) { if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post(); + if (ev.key === 'Escape') emit('esc'); } @@ -1083,6 +1084,15 @@ defineExpose({ margin: 12px 12px 12px 6px; vertical-align: bottom; + &:focus-visible { + outline: none; + + .submitInner { + outline: 2px solid var(--fgOnAccent); + outline-offset: -4px; + } + } + &:disabled { opacity: 0.7; } diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index ac37cb31bc..d6bca29050 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()"> +<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()" @esc="modal?.close()"> <MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/> </MkModal> </template> diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index 6676e3bf5b..22fc86723e 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]" :aria-checked="checked" :aria-disabled="disabled" + role="checkbox" @click="toggle" > <input @@ -69,6 +70,11 @@ function toggle(): void { border-color: var(--inputBorderHover) !important; } + &:focus-within { + outline: none; + box-shadow: 0 0 0 2px var(--focus); + } + &.checked { background-color: var(--accentedBg) !important; border-color: var(--accentedBg) !important; @@ -78,7 +84,7 @@ function toggle(): void { > .button { border-color: var(--accent); - &:after { + &::after { background-color: var(--accent); transform: scale(1); opacity: 1; @@ -104,7 +110,7 @@ function toggle(): void { border-radius: 100%; transition: inherit; - &:after { + &::after { content: ''; display: block; position: absolute; diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 358d9b1f4b..0eba8d6a9c 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -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; diff --git a/packages/frontend/src/components/MkSuperMenu.vue b/packages/frontend/src/components/MkSuperMenu.vue index 3023f63e5d..1a880170be 100644 --- a/packages/frontend/src/components/MkSuperMenu.vue +++ b/packages/frontend/src/components/MkSuperMenu.vue @@ -10,15 +10,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="items"> <template v-for="(item, i) in group.items"> - <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" class="_button item" :class="{ danger: item.danger, active: item.active }"> <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> <span class="text">{{ item.text }}</span> </a> - <button v-else-if="item.type === 'button'" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> + <button v-else-if="item.type === 'button'" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="ev => item.action(ev)"> <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> <span class="text">{{ item.text }}</span> </button> - <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <MkA v-else :to="item.to" class="_button item" :class="{ danger: item.danger, active: item.active }"> <span v-if="item.icon" class="icon"><i :class="item.icon" class="ti-fw"></i></span> <span class="text">{{ item.text }}</span> </MkA> @@ -67,6 +67,10 @@ defineProps<{ background: var(--panelHighlight); } + &:focus-visible { + outline-offset: -2px; + } + &.active { color: var(--accent); background: var(--accentedBg); diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 721ac357f4..a0994d9cc9 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -10,9 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only type="checkbox" :disabled="disabled" :class="$style.input" - @keydown.enter="toggle" + @click="toggle" > - <XButton :checked="checked" :disabled="disabled" @toggle="toggle"/> + <XButton :class="$style.toggle" :checked="checked" :disabled="disabled" @toggle="toggle"/> <span v-if="!noBody" :class="$style.body"> <!-- TODO: 無名slotの方は廃止 --> <span :class="$style.label"> @@ -75,7 +75,13 @@ const toggle = () => { height: 0; opacity: 0; margin: 0; + + &:focus-visible ~ .toggle { + outline: 2px solid var(--focus); + outline-offset: 2px; + } } + .body { margin-left: 12px; margin-top: 2px; diff --git a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue index e1d88b5e5c..27483cc7c2 100644 --- a/packages/frontend/src/components/MkTutorialDialog.PostNote.vue +++ b/packages/frontend/src/components/MkTutorialDialog.PostNote.vue @@ -105,7 +105,7 @@ const exampleCWNote = reactive<Misskey.entities.Note>({ font-weight: bold; text-align: left; - &:before { + &::before { content: ""; display: block; width: calc(100% - 38px); diff --git a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue index 7ae48dcd15..d8d4b5aab7 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Sensitive.vue @@ -115,7 +115,7 @@ const exampleNote = reactive<Misskey.entities.Note>({ font-weight: bold; text-align: left; - &:before { + &::before { content: ""; display: block; width: calc(100% - 38px); diff --git a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue index 57f26e86a7..6f2930ebc9 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue @@ -56,7 +56,7 @@ import { i18n } from '@/i18n.js'; font-weight: bold; text-align: left; - &:before { + &::before { content: ""; display: block; width: calc(100% - 38px); diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 5ecd41bfdf..75066bbc32 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')"> +<MkModal ref="modal" v-slot="{ type }" :zPriority="'high'" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()"> <div class="_popup" :class="{ [$style.root]: true, [$style.asDrawer]: type === 'drawer' }"> <div :class="[$style.label, $style.item]"> {{ i18n.ts.visibility }} diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 89993e1b8e..b12dc8cb31 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -8,7 +8,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div ref="headerEl"> <slot name="header"></slot> </div> - <div ref="bodyEl" :data-sticky-container-header-height="headerHeight"> + <div + ref="bodyEl" + :data-sticky-container-header-height="headerHeight" + :data-sticky-container-footer-height="footerHeight" + > <slot></slot> </div> <div ref="footerEl"> diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts index 0a7d136f18..0e5c7ede24 100644 --- a/packages/frontend/src/directives/hotkey.ts +++ b/packages/frontend/src/directives/hotkey.ts @@ -13,9 +13,9 @@ export default { el._keyHandler = makeHotkey(binding.value); if (el._hotkey_global) { - document.addEventListener('keydown', el._keyHandler); + document.addEventListener('keydown', el._keyHandler, { passive: false }); } else { - el.addEventListener('keydown', el._keyHandler); + el.addEventListener('keydown', el._keyHandler, { passive: false }); } }, diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index e855cf3d21..c79492d763 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -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'; @@ -25,6 +25,8 @@ import { MenuItem } from '@/types/menu.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { embedPage } from '@/config.js'; +import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; +import { focusParent } from '/scripts/focus.js'; export const openingWindowsCount = ref(0); @@ -634,33 +636,35 @@ 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 returnFocusTo = 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, + returnFocusTo, }, { closed: () => { resolve(); dispose(); + returnFocusTo = null; }, closing: () => { - if (options?.onClosing) options.onClosing(); + options?.onClosing?.(); }, }); - }); + })); } export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { if (embedPage) return Promise.resolve(); + let returnFocusTo = 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, @@ -668,14 +672,19 @@ export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> { closed: () => { resolve(); dispose(); + + // MkModalを通していないのでここでフォーカスを戻す処理を行う + if (returnFocusTo != null) { + focusParent(returnFocusTo, true, false); + returnFocusTo = null; + } }, }); - }); + })); } export function post(props: Record<string, any> = {}): Promise<void> { showMovedDialog(); - return new Promise(resolve => { // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 7a8786d415..a774412f83 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -234,6 +234,7 @@ onMounted(async () => { background-color: var(--accentedBg); color: var(--accent); text-decoration: none; + outline: none; } &.danger { diff --git a/packages/frontend/src/pages/games.vue b/packages/frontend/src/pages/games.vue index afd6df1ad9..b52f4decaa 100644 --- a/packages/frontend/src/pages/games.vue +++ b/packages/frontend/src/pages/games.vue @@ -8,12 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><MkPageHeader/></template> <MkSpacer :contentMax="800"> <div class="_gaps"> - <div class="_panel"> + <div class="_panel" :class="$style.link"> <MkA to="/bubble-game"> <img src="/client-assets/drop-and-fusion/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> </MkA> </div> - <div class="_panel"> + <div class="_panel" :class="$style.link"> <MkA to="/reversi"> <img src="/client-assets/reversi/logo.png" style="display: block; max-width: 100%; max-height: 200px; margin: auto;"/> </MkA> @@ -32,3 +32,10 @@ definePageMetadata(() => ({ icon: 'ti ti-device-gamepad', })); </script> + +<style module> +.link:focus-within { + outline: 2px solid var(--focus); + outline-offset: -2px; +} +</style> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index e73d032000..e2f04eb764 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -286,6 +286,7 @@ definePageMetadata(() => ({ background-color: var(--accentedBg); color: var(--accent); text-decoration: none; + outline: none; } } diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index b6f1043154..dace2cd847 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -113,8 +113,6 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'sound_note', 'sound_noteMy', 'sound_notification', - 'sound_antenna', - 'sound_channel', ]; const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ 'lightTheme', diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 60bf9b4d3d..a328933686 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -342,6 +342,7 @@ definePageMetadata(() => ({ &:hover, &:focus { opacity: .7; } + &:active { cursor: pointer; } diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index 090f0cf14c..0f1b725fae 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -54,8 +54,6 @@ const sounds = ref<Record<OperationType, Ref<SoundStore>>>({ note: defaultStore.reactiveState.sound_note, noteMy: defaultStore.reactiveState.sound_noteMy, notification: defaultStore.reactiveState.sound_notification, - antenna: defaultStore.reactiveState.sound_antenna, - channel: defaultStore.reactiveState.sound_channel, reaction: defaultStore.reactiveState.sound_reaction, }); diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 0a4bd4b826..7d192bcbea 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -213,12 +213,18 @@ definePageMetadata(() => ({ } } + .dn:focus-visible ~ .toggle { + outline: 2px solid var(--focus); + outline-offset: 2px; + } + .toggle { cursor: pointer; display: inline-block; position: relative; width: 90px; height: 50px; + margin: 4px; // focus用のアウトライン background-color: #83D8FF; border-radius: 90px - 6; transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; diff --git a/packages/frontend/src/pages/welcome.timeline.note.vue b/packages/frontend/src/pages/welcome.timeline.note.vue new file mode 100644 index 0000000000..f385938343 --- /dev/null +++ b/packages/frontend/src/pages/welcome.timeline.note.vue @@ -0,0 +1,109 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :key="note.id" :class="$style.note"> + <div class="_panel _gaps_s" :class="$style.content"> + <div v-if="note.cw != null" :class="$style.richcontent"> + <div><Mfm :text="note.cw" :author="note.user"/></div> + <MkCwButton v-model="showContent" :text="note.text" :renote="note.renote" :files="note.files" :poll="note.poll" style="margin: 4px 0;"/> + <div v-if="showContent"> + <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user"/> + <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + </div> + <div v-else ref="noteTextEl" :class="[$style.text, { [$style.collapsed]: shouldCollapse }]"> + <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user"/> + <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + <div v-if="note.files && note.files.length > 0" :class="$style.richcontent"> + <MkMediaList :mediaList="note.files.slice(0, 4)"/> + </div> + <div v-if="note.poll"> + <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> + </div> + <div v-if="note.reactionCount > 0" :class="$style.reactions"> + <MkReactionsViewer :note="note" :maxNumber="16"/> + </div> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref, shallowRef, onUpdated, onMounted } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; +import MkMediaList from '@/components/MkMediaList.vue'; +import MkPoll from '@/components/MkPoll.vue'; +import MkCwButton from '@/components/MkCwButton.vue'; + +defineProps<{ + note: Misskey.entities.Note; +}>(); + +const noteTextEl = shallowRef<HTMLDivElement>(); +const shouldCollapse = ref(false); +const showContent = ref(false); + +function calcCollapse() { + if (noteTextEl.value) { + const height = noteTextEl.value.scrollHeight; + if (height > 200) { + shouldCollapse.value = true; + } + } +} + +onMounted(() => { + calcCollapse(); +}); + +onUpdated(() => { + calcCollapse(); +}); +</script> + +<style lang="scss" module> +.note { + margin-left: auto; +} + +.text { + position: relative; + max-height: 200px; + overflow: hidden; + + &.collapsed::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } +} + +.content { + padding: 16px; + margin: 0 0 0 auto; + max-width: max-content; + border-radius: 16px; +} + +.reactions { + box-sizing: border-box; + margin: 8px -16px -8px; + padding: 8px 16px 0; + width: calc(100% + 32px); + border-top: 1px solid var(--divider); +} + +.richcontent { + min-width: 250px; +} +</style> diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index 139b2e0a07..db326f9e6c 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -4,24 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="$style.root"> - <div ref="scrollEl" :class="[$style.scrollbox, { [$style.scroll]: isScrolling }]"> - <div v-for="note in notes" :key="note.id" :class="$style.note"> - <div class="_panel" :class="$style.content"> - <div> - <MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :author="note.user"/> - <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA> - </div> - <div v-if="note.files.length > 0" :class="$style.richcontent"> - <MkMediaList :mediaList="note.files"/> - </div> - <div v-if="note.poll"> - <MkPoll :noteId="note.id" :poll="note.poll" :readOnly="true"/> - </div> - </div> - <MkReactionsViewer ref="reactionsViewer" :note="note"/> - </div> +<div :class="$style.root" class="_gaps"> + <div + ref="notesMainContainerEl" + class="_gaps" + :class="[$style.scrollBoxMain, { [$style.scrollIntro]: (scrollState === 'intro'), [$style.scrollLoop]: (scrollState === 'loop') }]" + @animationend="changeScrollState" + > + <XNote v-for="note in notes" :key="`${note.id}_1`" :class="$style.note" :note="note"/> + </div> + <div v-if="isScrolling" class="_gaps" :class="[$style.scrollBoxSub, { [$style.scrollIntro]: (scrollState === 'intro'), [$style.scrollLoop]: (scrollState === 'loop') }]"> + <XNote v-for="note in notes" :key="`${note.id}_2`" :class="$style.note" :note="note"/> </div> </div> </template> @@ -29,43 +22,54 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { onUpdated, ref, shallowRef } from 'vue'; -import MkReactionsViewer from '@/components/MkReactionsViewer.vue'; -import MkMediaList from '@/components/MkMediaList.vue'; -import MkPoll from '@/components/MkPoll.vue'; +import XNote from '@/pages/welcome.timeline.note.vue'; import { misskeyApiGet } from '@/scripts/misskey-api.js'; import { getScrollContainer } from '@/scripts/scroll.js'; const notes = ref<Misskey.entities.Note[]>([]); const isScrolling = ref(false); -const scrollEl = shallowRef<HTMLElement>(); +const scrollState = ref<null | 'intro' | 'loop'>(null); +const notesMainContainerEl = shallowRef<HTMLElement>(); misskeyApiGet('notes/featured').then(_notes => { notes.value = _notes; }); +function changeScrollState() { + if (scrollState.value !== 'loop') { + scrollState.value = 'loop'; + } +} + onUpdated(() => { - if (!scrollEl.value) return; - const container = getScrollContainer(scrollEl.value); + if (!notesMainContainerEl.value) return; + const container = getScrollContainer(notesMainContainerEl.value); const containerHeight = container ? container.clientHeight : window.innerHeight; - if (scrollEl.value.offsetHeight > containerHeight) { + if (notesMainContainerEl.value.offsetHeight > containerHeight) { + if (scrollState.value === null) { + scrollState.value = 'intro'; + } isScrolling.value = true; } }); </script> <style lang="scss" module> -@keyframes scroll { +@keyframes scrollIntro { 0% { transform: translate3d(0, 0, 0); } - 5% { - transform: translate3d(0, 0, 0); + 100% { + transform: translate3d(0, calc(calc(-100% - 128px) - var(--margin)), 0); } - 75% { - transform: translate3d(0, calc(-100% + 90vh), 0); +} + +@keyframes scrollConstant { + 0% { + transform: translate3d(0, -128px, 0); } - 90% { - transform: translate3d(0, calc(-100% + 90vh), 0); + 100% { + transform: translate3d(0, calc(calc(-100% - 128px) - var(--margin)), 0); } } @@ -73,24 +77,26 @@ onUpdated(() => { text-align: right; } -.scrollbox { - &.scroll { - animation: scroll 45s linear infinite; +.scrollBoxMain { + &.scrollIntro { + animation: scrollIntro 30s linear forwards; + } + &.scrollLoop { + animation: scrollConstant 30s linear infinite; } } -.note { - margin: 16px 0 16px auto; +.scrollBoxSub { + &.scrollIntro { + animation: scrollIntro 30s linear forwards; + } + &.scrollLoop { + animation: scrollConstant 30s linear infinite; + } } -.content { - padding: 16px; - margin: 0 0 0 auto; - max-width: max-content; - border-radius: 16px; -} - -.richcontent { - min-width: 250px; +.root:has(.note:hover) .scrollBoxMain, +.root:has(.note:hover) .scrollBoxSub { + animation-play-state: paused; } </style> diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/scripts/focus-trap.ts new file mode 100644 index 0000000000..734c73652f --- /dev/null +++ b/packages/frontend/src/scripts/focus-trap.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js'; + +const focusTrapElements = new Set<HTMLElement>(); +const ignoreElements = [ + 'script', + 'style', +]; + +function containsFocusTrappedElements(el: HTMLElement): boolean { + return Array.from(focusTrapElements).some((focusTrapElement) => { + return el.contains(focusTrapElement); + }); +} + +function releaseFocusTrap(el: HTMLElement): void { + focusTrapElements.delete(el); + if (el.parentElement != null && el !== document.body) { + el.parentElement.childNodes.forEach((siblingNode) => { + const siblingEl = getHTMLElementOrNull(siblingNode); + if (!siblingEl) return; + if (siblingEl !== el && (focusTrapElements.has(siblingEl) || containsFocusTrappedElements(siblingEl) || focusTrapElements.size === 0)) { + siblingEl.inert = false; + } else if ( + focusTrapElements.size > 0 && + !containsFocusTrappedElements(siblingEl) && + !focusTrapElements.has(siblingEl) && + !ignoreElements.includes(siblingEl.tagName.toLowerCase()) + ) { + siblingEl.inert = true; + } else { + siblingEl.inert = false; + } + }); + releaseFocusTrap(el.parentElement); + } +} + +export function focusTrap(el: HTMLElement, parent: true): void; +export function focusTrap(el: HTMLElement, parent?: false): { release: () => void; }; +export function focusTrap(el: HTMLElement, parent = false): { release: () => void; } | void { + if (el.parentElement != null && el !== document.body) { + el.parentElement.childNodes.forEach((siblingNode) => { + const siblingEl = getHTMLElementOrNull(siblingNode); + if (!siblingEl) return; + if (siblingEl !== el && !ignoreElements.includes(siblingEl.tagName.toLowerCase())) { + siblingEl.inert = true; + } + }); + focusTrap(el.parentElement, true); + } + + if (!parent) { + focusTrapElements.add(el); + + return { + release: () => { + releaseFocusTrap(el); + }, + }; + } +} diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts index ea6ee61c88..eb2da5ad86 100644 --- a/packages/frontend/src/scripts/focus.ts +++ b/packages/frontend/src/scripts/focus.ts @@ -3,30 +3,78 @@ * 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); - } - } -} +import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@/scripts/scroll.js'; +import { getElementOrNull, getNodeOrNull } from '@/scripts/get-dom-node-or-null.js'; -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); - } +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 (scroll) { + const scrollContainer = getScrollContainer(element) ?? document.documentElement; + const scrollContainerTop = getScrollPosition(scrollContainer); + const stickyTop = getStickyTop(element, scrollContainer); + const stickyBottom = getStickyBottom(element, scrollContainer); + const top = element.getBoundingClientRect().top; + const bottom = element.getBoundingClientRect().bottom; + + let scrollTo = scrollContainerTop; + if (top < stickyTop) { + scrollTo += top - stickyTop; + } else if (bottom > window.innerHeight - stickyBottom) { + scrollTo += bottom - window.innerHeight + stickyBottom; + } + scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' }); + } + + if (document.activeElement !== element) { + element.focus({ preventScroll: true }); + } +}; diff --git a/packages/frontend/src/scripts/get-dom-node-or-null.ts b/packages/frontend/src/scripts/get-dom-node-or-null.ts new file mode 100644 index 0000000000..fbf54675fd --- /dev/null +++ b/packages/frontend/src/scripts/get-dom-node-or-null.ts @@ -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; +}; diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts index fd79baa604..ff3cbe98ac 100644 --- a/packages/frontend/src/scripts/hotkey.ts +++ b/packages/frontend/src/scripts/hotkey.ts @@ -2,6 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import { getHTMLElementOrNull } from "@/scripts/get-dom-node-or-null.js"; //#region types export type Keymap = Record<string, CallbackFunction | CallbackObject>; @@ -30,8 +31,8 @@ type Action = { //#region consts const KEY_ALIASES = { 'esc': 'Escape', - 'enter': ['Enter', 'NumpadEnter'], - 'space': [' ', 'Spacebar'], + 'enter': 'Enter', + 'space': ' ', 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', @@ -44,6 +45,10 @@ const MODIFIER_KEYS = ['ctrl', 'alt', 'shift']; const IGNORE_ELEMENTS = ['input', 'textarea']; //#endregion +//#region store +let latestHotkey: Pattern & { callback: CallbackFunction } | null = null; +//#endregion + //#region impl export const makeHotkey = (keymap: Keymap) => { const actions = parseKeymap(keymap); @@ -51,13 +56,14 @@ export const makeHotkey = (keymap: Keymap) => { if ('pswp' in window && window.pswp != null) return; if (document.activeElement != null) { if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return; - if ((document.activeElement as HTMLElement).isContentEditable) return; + if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return; } - for (const { patterns, callback, options } of actions) { - if (matchPatterns(ev, patterns, options)) { + for (const action of actions) { + if (matchPatterns(ev, action)) { ev.preventDefault(); ev.stopPropagation(); - callback(ev); + action.callback(ev); + storePattern(ev, action.callback); } } }; @@ -102,10 +108,21 @@ const parseOptions = (rawCallback: Keymap[keyof Keymap]) => { return { ...defaultOptions } as const satisfies Action['options']; }; -const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options: Action['options']) => { +const matchPatterns = (ev: KeyboardEvent, action: Action) => { + const { patterns, options, callback } = action; if (ev.repeat && !options.allowRepeat) return false; const key = ev.key.toLowerCase(); return patterns.some(({ which, ctrl, shift, alt }) => { + if ( + latestHotkey != null && + latestHotkey.which.includes(key) && + latestHotkey.ctrl === ctrl && + latestHotkey.alt === alt && + latestHotkey.shift === shift && + latestHotkey.callback === callback + ) { + return false; + } if (!which.includes(key)) return false; if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false; if (alt !== ev.altKey) return false; @@ -114,6 +131,26 @@ const matchPatterns = (ev: KeyboardEvent, patterns: Action['patterns'], options: }); }; +let lastHotKeyStoreTimer: number | null = null; + +const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => { + if (lastHotKeyStoreTimer != null) { + clearTimeout(lastHotKeyStoreTimer); + } + + latestHotkey = { + which: [ev.key.toLowerCase()], + ctrl: ev.ctrlKey || ev.metaKey, + alt: ev.altKey, + shift: ev.shiftKey, + callback, + }; + + lastHotKeyStoreTimer = window.setTimeout(() => { + latestHotkey = null; + }, 500); +}; + const parseKeyCode = (input?: string | null) => { if (input == null) return []; const raw = getValueByKey(KEY_ALIASES, input); diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts index 1dc013fdfc..ad6282c3e4 100644 --- a/packages/frontend/src/scripts/scroll.ts +++ b/packages/frontend/src/scripts/scroll.ts @@ -24,6 +24,14 @@ export function getStickyTop(el: HTMLElement, container: HTMLElement | null = nu return getStickyTop(el.parentElement, container, newTop); } +export function getStickyBottom(el: HTMLElement, container: HTMLElement | null = null, bottom = 0) { + if (!el.parentElement) return bottom; + const data = el.dataset.stickyContainerFooterHeight; + const newBottom = data ? Number(data) + bottom : bottom; + if (el === container) return newBottom; + return getStickyBottom(el.parentElement, container, newBottom); +} + export function getScrollPosition(el: HTMLElement | null): number { const container = getScrollContainer(el); return container == null ? window.scrollY : container.scrollTop; diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index fcd59510df..bba855cd64 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -74,8 +74,6 @@ export const soundsTypes = [ export const operationTypes = [ 'noteMy', 'note', - 'antenna', - 'channel', 'notification', 'reaction', ] as const; diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index e8eb5a1ed7..9cb2742069 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -479,14 +479,6 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore, }, - sound_antenna: { - where: 'device', - default: { type: 'syuilo/triple', volume: 1 } as SoundStore, - }, - sound_channel: { - where: 'device', - default: { type: 'syuilo/square-pico', volume: 1 } as SoundStore, - }, sound_reaction: { where: 'device', default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore, diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 7f602c46f9..df2532f812 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -115,6 +115,10 @@ a { -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; + &:focus-visible { + outline-offset: 2px; + } + &:hover { text-decoration: underline; } @@ -145,12 +149,21 @@ 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%; line-height: 1em; - &:before { + &::before { font-size: 128%; } } @@ -232,10 +245,6 @@ rt { line-height: inherit; max-width: 100%; - &:focus-visible { - outline: none; - } - &:disabled { opacity: 0.5; cursor: default; @@ -272,13 +281,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; } diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 822b552837..d7df2d10f9 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -227,7 +227,7 @@ if ($i) { right: 15px; pointer-events: none; - &:before { + &::before { content: ""; display: block; width: 18px; diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index 699aa1e1c8..87e9e45e63 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -139,7 +139,7 @@ function more() { font-weight: bold; text-align: left; - &:before { + &::before { content: ""; display: block; width: calc(100% - 38px); @@ -155,7 +155,7 @@ function more() { } &:hover, &.active { - &:before { + &::before { background: var(--accentLighten); } } @@ -226,7 +226,7 @@ function more() { } &:hover, &.active { - &:before { + &::before { content: ""; display: block; width: calc(100% - 24px); diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue index b029533f28..8307da0d42 100644 --- a/packages/frontend/src/ui/_common_/navbar.vue +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -166,6 +166,15 @@ function more(ev: MouseEvent) { display: block; text-align: center; width: 100%; + + &:focus-visible { + outline: none; + + > .instanceIcon { + outline: 2px solid var(--focus); + outline-offset: 2px; + } + } } .instanceIcon { @@ -192,7 +201,7 @@ function more(ev: MouseEvent) { font-weight: bold; text-align: left; - &:before { + &::before { content: ""; display: block; width: calc(100% - 38px); @@ -207,8 +216,17 @@ function more(ev: MouseEvent) { background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); } + &:focus-visible { + outline: none; + + &::before { + outline: 2px solid var(--fgOnAccent); + outline-offset: -4px; + } + } + &:hover, &.active { - &:before { + &::before { background: var(--accentLighten); } } @@ -234,6 +252,14 @@ function more(ev: MouseEvent) { text-align: left; box-sizing: border-box; overflow: clip; + + &:focus-visible { + outline: none; + + > .avatar { + box-shadow: 0 0 0 4px var(--focus); + } + } } .avatar { @@ -282,10 +308,19 @@ function more(ev: MouseEvent) { color: var(--navActive); } - &:hover, &.active { + &:focus-visible { + outline: none; + + &::before { + outline: 2px solid var(--focus); + outline-offset: -2px; + } + } + + &:hover, &.active, &:focus { color: var(--accent); - &:before { + &::before { content: ""; display: block; width: calc(100% - 34px); @@ -352,6 +387,15 @@ function more(ev: MouseEvent) { display: block; text-align: center; width: 100%; + + &:focus-visible { + outline: none; + + > .instanceIcon { + outline: 2px solid var(--focus); + outline-offset: 2px; + } + } } .instanceIcon { @@ -376,7 +420,7 @@ function more(ev: MouseEvent) { height: 52px; text-align: center; - &:before { + &::before { content: ""; display: block; position: absolute; @@ -391,8 +435,17 @@ function more(ev: MouseEvent) { background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); } + &:focus-visible { + outline: none; + + &::before { + outline: 2px solid var(--fgOnAccent); + outline-offset: -4px; + } + } + &:hover, &.active { - &:before { + &::before { background: var(--accentLighten); } } @@ -413,6 +466,14 @@ function more(ev: MouseEvent) { padding: 20px 0; width: 100%; overflow: clip; + + &:focus-visible { + outline: none; + + > .avatar { + box-shadow: 0 0 0 4px var(--focus); + } + } } .avatar { @@ -442,11 +503,20 @@ function more(ev: MouseEvent) { width: 100%; text-align: center; - &:hover, &.active { + &:focus-visible { + outline: none; + + &::before { + outline: 2px solid var(--focus); + outline-offset: -2px; + } + } + + &:hover, &.active, &:focus { text-decoration: none; color: var(--accent); - &:before { + &::before { content: ""; display: block; height: 100%; diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 07845bacbb..e96402d13b 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -271,7 +271,7 @@ function onDrop(ev) { border-radius: 10px; &.draghover { - &:after { + &::after { content: ""; display: block; position: absolute; @@ -285,7 +285,7 @@ function onDrop(ev) { } &.dragging { - &:after { + &::after { content: ""; display: block; position: absolute; diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue index c688e8a0b1..6ece33eff3 100644 --- a/packages/frontend/src/widgets/WidgetCalendar.vue +++ b/packages/frontend/src/widgets/WidgetCalendar.vue @@ -121,7 +121,7 @@ defineExpose<WidgetComponentExpose>({ .root { padding: 16px 0; - &:after { + &::after { content: ""; display: block; clear: both;