feat: improve accessibility features (#869)

This commit is contained in:
pengzhanbo 2026-04-02 20:49:20 +08:00 committed by GitHub
parent 39a76a35d7
commit fe0d4bbc92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 138 additions and 21 deletions

View File

@ -2,7 +2,7 @@
import VPIcon from '@theme/VPIcon.vue'
import { computed, toRef } from 'vue'
import { useRouter, withBase } from 'vuepress/client'
import { useLink } from '../composables/index.js'
import { useData, useLink } from '../composables/index.js'
interface Props {
tag?: string
@ -21,7 +21,7 @@ const props = withDefaults(defineProps<Props>(), {
text: '',
})
const router = useRouter()
const { theme: themeData } = useData()
const component = computed(() => {
return props.tag || props.href ? 'a' : 'button'
})
@ -44,12 +44,15 @@ function linkTo(e: Event) {
:class="[size, theme]"
:href=" link ? link[0] === '#' || isExternalProtocol ? link : withBase(link) : undefined"
:target="target ?? (isExternal ? '_blank' : undefined)"
:rel="rel ?? (isExternal ? 'noreferrer' : undefined)"
:rel="rel ?? (isExternal ? 'noopener noreferrer' : undefined)"
@click="linkTo($event)"
>
<span class="button-content">
<VPIcon v-if="icon" :name="icon" />
<slot><span>{{ text }}</span></slot>
<span v-if="isExternal" class="visually-hidden">
{{ themeData.openNewWindowText || '(Open in new window)' }}
</span>
<VPIcon v-if="suffixIcon" :name="suffixIcon" />
</span>
</Component>
@ -73,6 +76,10 @@ function linkTo(e: Event) {
background-color 0.1s;
}
.vp-button:focus-visible {
outline-offset: 4px;
}
.vp-button.medium {
padding: 0 20px;
font-size: 14px;

View File

@ -51,6 +51,8 @@ async function onSubmit() {
type="password"
autocomplete="off"
:placeholder="theme.encryptPlaceholder ?? 'Enter Password'"
:aria-invalid="errorCode === 1"
:aria-describedby="errorCode === 1 ? 'encrypt-error' : undefined"
@keyup.enter="onSubmit"
@focus="!password && (errorCode = 0)"
@input="password && (errorCode = 0)"

View File

@ -138,6 +138,10 @@ function onBlur() {
transition: color var(--vp-t-color);
}
.vp-flyout .button:focus-visible {
outline-offset: 4px;
}
.option-icon {
margin-right: 0;
font-size: 16px;

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, toRef } from 'vue'
import { useRouter, withBase } from 'vuepress/client'
import { useLink } from '../composables/index.js'
import { useData, useLink } from '../composables/index.js'
const props = defineProps<{
tag?: string
@ -13,6 +13,7 @@ const props = defineProps<{
}>()
const router = useRouter()
const { theme } = useData()
const tag = computed(() => props.tag ?? (props.href ? 'a' : 'span'))
@ -32,11 +33,20 @@ function linkTo(e: Event) {
class="vp-link" :class="{ link, 'no-icon': noIcon, 'vp-external-link-icon': isExternal }"
:href="link ? isExternalProtocol ? link : isExternal ? link : withBase(link) : undefined"
:target="target ?? (isExternal ? '_blank' : undefined)"
:rel="rel ?? (isExternal ? 'noreferrer' : undefined)"
:rel="rel ?? (isExternal ? 'noopener noreferrer' : undefined)"
@click="linkTo($event)"
>
<slot>
{{ text || href }}
</slot>
<span v-if="isExternal && !noIcon" class="visually-hidden">
{{ theme.openNewWindowText || '(Open in new window)' }}
</span>
</Component>
</template>
<style>
.vp-link:focus-visible {
border-radius: 2px;
}
</style>

View File

@ -58,12 +58,6 @@ function onItemInteraction(e: MouseEvent | Event) {
toggle()
}
}
function onCaretClick() {
if (item.link) {
toggle()
}
}
</script>
<template>
@ -107,17 +101,16 @@ function onCaretClick() {
/>
</Component>
<div
<button
v-if="item.collapsed != null"
type="button"
class="caret"
role="button"
aria-label="toggle section"
tabindex="0"
@click="onCaretClick"
@keydown.enter="onCaretClick"
:aria-label="`${collapsed ? 'Expand' : 'Collapse'} ${item.text}`"
:aria-expanded="!collapsed"
tabindex="-1"
>
<span class="vpi-chevron-right caret-icon" />
</div>
</button>
</div>
<template v-if="item.items && item.items.length && depth < 5">

View File

@ -56,6 +56,11 @@ const label = computed(() => {
color: var(--vp-c-text-1);
}
.vp-social-link:focus-visible {
border-radius: 50%;
outline-offset: 4px;
}
.vp-social-link > :deep([class*="vpi-"]),
.vp-social-link > :deep(.vp-icon.is-svg) {
width: 20px;

View File

@ -1,6 +1,16 @@
<script lang="ts" setup>
defineProps<{
ariaChecked?: boolean
}>()
</script>
<template>
<!-- eslint-disable-next-line vue-a11y/role-has-required-aria-props -->
<button class="vp-switch" type="button" role="switch">
<button
class="vp-switch"
type="button"
role="switch"
:aria-checked="ariaChecked ?? false"
>
<span class="check">
<span v-if="$slots.default" class="icon">
<slot />
@ -28,6 +38,10 @@
border-color: var(--vp-c-brand-1);
}
.vp-switch:focus-visible {
outline-offset: 4px;
}
.check {
position: absolute;
top: 1px;

View File

@ -6,6 +6,10 @@ import { enableTransitions, resolveTransitionKeyframes, useData } from '../compo
const checked = ref(false)
const { theme, isDark } = useData()
watchPostEffect(() => {
checked.value = isDark.value
})
const transitionMode = computed(() => {
const transition = theme.value.transition
const options = typeof transition === 'object' ? transition : {}
@ -15,8 +19,12 @@ const transitionMode = computed(() => {
return typeof options.appearance === 'string' ? options.appearance : 'fade'
})
function shouldReduceMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
const toggleAppearance = inject('toggle-appearance', async ({ clientX, clientY }: MouseEvent) => {
if (!enableTransitions() || transitionMode.value === false) {
if (!enableTransitions() || transitionMode.value === false || shouldReduceMotion()) {
isDark.value = !isDark.value
return
}
@ -80,6 +88,12 @@ watchPostEffect(() => {
/* rtl:ignore */
transform: translateX(18px);
}
@media (prefers-reduced-motion: reduce) {
.vp-switch-appearance :deep(.check) {
transition: none !important;
}
}
</style>
<style>

View File

@ -262,6 +262,11 @@ figure {
margin: 0;
}
:where(#app) :focus-visible {
outline: var(--vp-focus-ring-width) solid var(--vp-focus-ring-color);
outline-offset: var(--vp-focus-ring-offset);
}
h1,
h2,
h3,

View File

@ -703,3 +703,12 @@
--vp-c-control-hover: var(--vp-c-default-2);
--vp-c-control-disabled: var(--vp-c-default-soft);
}
/**
* Focus ring
* -------------------------------------------------------------------------- */
:root {
--vp-focus-ring-color: var(--vp-c-brand-1);
--vp-focus-ring-width: 2px;
--vp-focus-ring-offset: 2px;
}

View File

@ -28,6 +28,8 @@ export const deLocale: ThemeLocaleText = {
copyrightCreationReprintText: 'Nachdruck von:',
copyrightLicenseText: 'Lizenz:',
openNewWindowText: '(In neuem Fenster öffnen)',
notFound: {
code: '404',
title: 'Seite nicht gefunden',

View File

@ -22,6 +22,8 @@ export const enLocale: ThemeLocaleText = {
copyrightCreationReprintText: 'This article is reprint from:',
copyrightLicenseText: 'License under:',
openNewWindowText: '(Open in new window)',
encryptButtonText: 'Confirm',
encryptPlaceholder: 'Enter password',
encryptGlobalText: 'Only password can access this site',

View File

@ -28,6 +28,8 @@ export const frLocale: ThemeLocaleText = {
copyrightCreationReprintText: 'Reproduit de :',
copyrightLicenseText: 'Licence :',
openNewWindowText: '(Ouvrir dans une nouvelle fenêtre)',
notFound: {
code: '404',
title: 'Page non trouvée',

View File

@ -28,6 +28,8 @@ export const jaLocale: ThemeLocaleText = {
copyrightCreationReprintText: '本文の転載元:',
copyrightLicenseText: 'ライセンス:',
openNewWindowText: '(新しいウィンドウで開く)',
notFound: {
code: '404',
title: 'ページが見つかりません',

View File

@ -40,6 +40,8 @@ export const koLocale: ThemeLocaleText = {
categoryText: '카테고리',
archiveTotalText: '{count}개의 글',
openNewWindowText: '(새 창에서 열기)',
notFound: {
code: '404',
title: '페이지를 찾을 수 없습니다',

View File

@ -28,6 +28,8 @@ export const ruLocale: ThemeLocaleText = {
copyrightCreationReprintText: 'Перепечатано из:',
copyrightLicenseText: 'Лицензия:',
openNewWindowText: '(Открыть в новой вкладке)',
notFound: {
code: '404',
title: 'Страница не найдена',

View File

@ -28,6 +28,8 @@ export const zhTwLocale: ThemeLocaleText = {
copyrightCreationReprintText: '本文轉載自:',
copyrightLicenseText: '授權條款:',
openNewWindowText: '(在新窗口打開)',
notFound: {
code: '404',
title: '頁面未找到',

View File

@ -27,6 +27,8 @@ export const zhLocale: ThemeLocaleText = {
copyrightCreationReprintText: '本文转载自:',
copyrightLicenseText: '许可证:',
openNewWindowText: '(在新窗口打开)',
notFound: {
code: '404',
title: '页面未找到',

View File

@ -339,6 +339,13 @@ export interface ThemeLocaleText {
*/
nextPageLabel?: string
/**
*
*
* @default '(Open in new window)'
*/
openNewWindowText?: string
/**
* 404
*/
@ -404,13 +411,44 @@ export interface ThemeLocaleText {
*/
encryptPlaceholder?: string
// 以下字段与 PageContextMenu 相关 ------ start
/**
*
*/
copyPageText?: string
/**
*
*/
copiedPageText?: string
/**
*
*/
copingPageText?: string
/**
*
*/
copyTagline?: string
/**
* Markdown
*/
viewMarkdown?: string
/**
* Markdown
*/
viewMarkdownTagline?: string
/**
* AI
*/
askAIText?: string
/**
* AI
*/
askAITagline?: string
/**
* AI
*/
askAIMessage?: string
// 以上字段与 PageContextMenu 相关 ------ end
}