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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -262,6 +262,11 @@ figure {
margin: 0; 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, h1,
h2, h2,
h3, h3,

View File

@ -703,3 +703,12 @@
--vp-c-control-hover: var(--vp-c-default-2); --vp-c-control-hover: var(--vp-c-default-2);
--vp-c-control-disabled: var(--vp-c-default-soft); --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:', copyrightCreationReprintText: 'Nachdruck von:',
copyrightLicenseText: 'Lizenz:', copyrightLicenseText: 'Lizenz:',
openNewWindowText: '(In neuem Fenster öffnen)',
notFound: { notFound: {
code: '404', code: '404',
title: 'Seite nicht gefunden', title: 'Seite nicht gefunden',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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