-
-
-
-
-
- RN: ...
-
-
-
-
-
-
-
-
-
-
+
@@ -29,43 +22,54 @@ SPDX-License-Identifier: AGPL-3.0-only
From 121af778a0925d5850e9d88261e9a8e8c6fd968b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
<67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Thu, 11 Jul 2024 18:44:18 +0900
Subject: [PATCH 2/3] =?UTF-8?q?enhance(frontend):=20=E6=9C=AA=E4=BD=BF?=
=?UTF-8?q?=E7=94=A8=E3=81=AE=E3=82=B5=E3=82=A6=E3=83=B3=E3=83=89=E8=A8=AD?=
=?UTF-8?q?=E5=AE=9A=E3=82=92=E5=89=8A=E9=99=A4=20(#14116)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* enhance(frontend): 未使用のサウンド設定を削除
* Update Changelog
* Update CHANGELOG.md
---
CHANGELOG.md | 3 +++
locales/index.d.ts | 8 --------
locales/ja-JP.yml | 2 --
.../frontend/src/pages/settings/preferences-backups.vue | 2 --
packages/frontend/src/pages/settings/sounds.vue | 2 --
packages/frontend/src/scripts/sound.ts | 2 --
packages/frontend/src/store.ts | 8 --------
7 files changed, 3 insertions(+), 24 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 259420a6c6..cd123c938e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
## Unreleased
+### Note
+- デッキUIの新着ノートをサウンドで通知する機能の追加(v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。
+
### General
- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
diff --git a/locales/index.d.ts b/locales/index.d.ts
index ebd980ed85..5089f7802e 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -7515,14 +7515,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 0d89d33abe..a03d792140 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1971,8 +1971,6 @@ _sfx:
note: "ノート"
noteMy: "ノート(自分)"
notification: "通知"
- antenna: "アンテナ受信"
- channel: "チャンネル通知"
reaction: "リアクション選択時"
_soundSettings:
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/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
>>({
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/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,
From 385969e9f56a39a1e5547b94901d155e1e811263 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?=
<67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Fri, 12 Jul 2024 16:25:44 +0900
Subject: [PATCH 3/3] =?UTF-8?q?fix(frontend):=20=E3=83=95=E3=82=A9?=
=?UTF-8?q?=E3=83=BC=E3=82=AB=E3=82=B9=E3=81=AE=E6=8C=99=E5=8B=95=E3=82=92?=
=?UTF-8?q?=E4=BF=AE=E6=AD=A3=20(#14158)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(frontend): 直前のパターンを記録するように
* fix(frontend): フォーカス/タブ移動に関する挙動を調整 (#226)
Cherry-pick commit e8c030673326871edf3623cf2b8675d68f9e1b13
Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com>
* focusのデザイン修正
* move scripts
* Modalにfocus trapを追加
* 記録するホットキーはレートリミット式にする
* escキーのハンドリングをMkModalに統一
* fix
* enterで子メニューを開けるように
* lint
* fix focus trap
* improve switch accessibility
* 一部のmodalのフォーカストラップが外れない問題を修正
* fix
* fix
* Revert "記録するホットキーはレートリミット式にする"
This reverts commit 40a7509286a87911ad4cc06d9482e8a2e5d0e7e8.
* Revert "fix(frontend): 直前のパターンを記録するように"
This reverts commit 5372b2594023952cff34aa62253ed4efef15b5dd.
* Revert "Revert "fix(frontend): 直前のパターンを記録するように""
This reverts commit a9bb52e799e110927ad92cd8f26af980819334e1.
* Revert "Revert "記録するホットキーはレートリミット式にする""
This reverts commit bdac34273e0bc5f13604c7e2f9fa6b1321a0df3d.
* 試験的にCypressでのFocustrapを無効化
* fix
* fix focus-trap
* Update Changelog
* :v:
* fix focustrap invocation logic
* スクロールがsticky headerを考慮するように
* :art:
* スタイルの微調整
* :art:
* remove deprecated key aliases
* focusElementが足りなかったので修正
* preview系にfocus時スタイルが足りなかったので修正
* `returnFocusElement` -> `returnFocusTo`
* lint
* Update packages/frontend/src/components/MkModalWindow.vue
* Apply suggestions from code review
Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com>
* keydownイベントをまとめる
* use correct pesudo-element selector
* fix
* rename
---------
Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
CHANGELOG.md | 2 +
.../src/components/MkAchievements.vue | 4 +-
packages/frontend/src/components/MkButton.vue | 1 -
.../src/components/MkChannelFollowButton.vue | 12 +-
.../src/components/MkChannelPreview.vue | 19 +-
.../frontend/src/components/MkClipPreview.vue | 8 +
.../frontend/src/components/MkContextMenu.vue | 2 +-
.../frontend/src/components/MkCwButton.vue | 4 +-
packages/frontend/src/components/MkDialog.vue | 16 +-
.../frontend/src/components/MkDrive.file.vue | 32 +-
.../src/components/MkDrive.folder.vue | 2 +-
.../frontend/src/components/MkEmojiPicker.vue | 33 +-
.../src/components/MkEmojiPickerDialog.vue | 2 +
.../src/components/MkFlashPreview.vue | 6 +-
packages/frontend/src/components/MkFolder.vue | 16 +-
.../src/components/MkFollowButton.vue | 12 +-
.../src/components/MkGalleryPostPreview.vue | 4 +-
.../src/components/MkImgWithBlurhash.vue | 4 +-
.../frontend/src/components/MkLaunchPad.vue | 2 +-
.../frontend/src/components/MkMediaAudio.vue | 26 +-
.../frontend/src/components/MkMediaList.vue | 54 ++-
.../frontend/src/components/MkMediaVideo.vue | 10 +-
.../frontend/src/components/MkMenu.child.vue | 5 +-
packages/frontend/src/components/MkMenu.vue | 367 +++++++++++-------
packages/frontend/src/components/MkModal.vue | 28 +-
.../frontend/src/components/MkModalWindow.vue | 13 +-
packages/frontend/src/components/MkNote.vue | 48 +--
.../src/components/MkNoteDetailed.vue | 62 +--
.../frontend/src/components/MkNotePreview.vue | 2 +-
.../src/components/MkNotification.vue | 2 +-
.../frontend/src/components/MkPagePreview.vue | 19 +-
.../frontend/src/components/MkPopupMenu.vue | 6 +-
.../frontend/src/components/MkPostForm.vue | 10 +
.../src/components/MkPostFormDialog.vue | 2 +-
packages/frontend/src/components/MkRadio.vue | 10 +-
packages/frontend/src/components/MkSelect.vue | 27 +-
.../frontend/src/components/MkSuperMenu.vue | 10 +-
packages/frontend/src/components/MkSwitch.vue | 10 +-
.../components/MkTutorialDialog.PostNote.vue | 2 +-
.../components/MkTutorialDialog.Sensitive.vue | 2 +-
.../components/MkTutorialDialog.Timeline.vue | 2 +-
.../src/components/MkVisibilityPicker.vue | 2 +-
.../components/global/MkStickyContainer.vue | 6 +-
packages/frontend/src/directives/hotkey.ts | 4 +-
packages/frontend/src/os.ts | 27 +-
.../frontend/src/pages/drive.file.info.vue | 1 +
packages/frontend/src/pages/games.vue | 11 +-
packages/frontend/src/pages/page.vue | 1 +
.../frontend/src/pages/settings/profile.vue | 1 +
.../frontend/src/pages/settings/theme.vue | 6 +
packages/frontend/src/scripts/focus-trap.ts | 65 ++++
packages/frontend/src/scripts/focus.ts | 98 +++--
.../src/scripts/get-dom-node-or-null.ts | 19 +
packages/frontend/src/scripts/hotkey.ts | 51 ++-
packages/frontend/src/scripts/scroll.ts | 8 +
packages/frontend/src/style.scss | 25 +-
packages/frontend/src/ui/_common_/common.vue | 2 +-
.../src/ui/_common_/navbar-for-mobile.vue | 6 +-
packages/frontend/src/ui/_common_/navbar.vue | 86 +++-
packages/frontend/src/ui/deck/column.vue | 4 +-
.../frontend/src/widgets/WidgetCalendar.vue | 2 +-
61 files changed, 932 insertions(+), 391 deletions(-)
create mode 100644 packages/frontend/src/scripts/focus-trap.ts
create mode 100644 packages/frontend/src/scripts/get-dom-node-or-null.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd123c938e..c6f48684b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@
### Client
- Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善
- Enhance: 非ログイン時のハイライトTLのデザインを改善
+- Enhance: フロントエンドのアクセシビリティ改善
+ (Based on https://github.com/taiyme/misskey/pull/226)
- Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正
- Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968)
- Fix: リバーシの対局を正しく共有できないことがある問題を修正
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
-
+
{{ channel.name }}
@@ -80,6 +80,7 @@ const bannerStyle = computed(() => {
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/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/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/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
();
+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;
@@ -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 8edb6fca05..f0274034b5 100644
--- a/packages/frontend/src/scripts/scroll.ts
+++ b/packages/frontend/src/scripts/scroll.ts
@@ -23,6 +23,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/style.scss b/packages/frontend/src/style.scss
index 250a2616a7..2feb79ef81 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -113,6 +113,10 @@ a {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
+ &:focus-visible {
+ outline-offset: 2px;
+ }
+
&:hover {
text-decoration: underline;
}
@@ -143,12 +147,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%;
}
}
@@ -230,10 +243,6 @@ rt {
line-height: inherit;
max-width: 100%;
- &:focus-visible {
- outline: none;
- }
-
&:disabled {
opacity: 0.5;
cursor: default;
@@ -270,13 +279,17 @@ rt {
._help {
color: var(--accent);
- cursor: help
+ cursor: help;
}
._textButton {
@extend ._button;
color: var(--accent);
+ &:focus-visible {
+ outline-offset: 2px;
+ }
+
&:not(:disabled):hover {
text-decoration: underline;
}
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({
.root {
padding: 16px 0;
- &:after {
+ &::after {
content: "";
display: block;
clear: both;