mirror of
https://github.com/pengzhanbo/vuepress-theme-plume.git
synced 2026-04-23 10:58:13 +08:00
feat: improve accessibility features (#869)
This commit is contained in:
parent
39a76a35d7
commit
fe0d4bbc92
@ -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;
|
||||||
|
|||||||
@ -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)"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
5
theme/src/client/styles/normalize.css
vendored
5
theme/src/client/styles/normalize.css
vendored
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export const jaLocale: ThemeLocaleText = {
|
|||||||
copyrightCreationReprintText: '本文の転載元:',
|
copyrightCreationReprintText: '本文の転載元:',
|
||||||
copyrightLicenseText: 'ライセンス:',
|
copyrightLicenseText: 'ライセンス:',
|
||||||
|
|
||||||
|
openNewWindowText: '(新しいウィンドウで開く)',
|
||||||
|
|
||||||
notFound: {
|
notFound: {
|
||||||
code: '404',
|
code: '404',
|
||||||
title: 'ページが見つかりません',
|
title: 'ページが見つかりません',
|
||||||
|
|||||||
@ -40,6 +40,8 @@ export const koLocale: ThemeLocaleText = {
|
|||||||
categoryText: '카테고리',
|
categoryText: '카테고리',
|
||||||
archiveTotalText: '{count}개의 글',
|
archiveTotalText: '{count}개의 글',
|
||||||
|
|
||||||
|
openNewWindowText: '(새 창에서 열기)',
|
||||||
|
|
||||||
notFound: {
|
notFound: {
|
||||||
code: '404',
|
code: '404',
|
||||||
title: '페이지를 찾을 수 없습니다',
|
title: '페이지를 찾을 수 없습니다',
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export const ruLocale: ThemeLocaleText = {
|
|||||||
copyrightCreationReprintText: 'Перепечатано из:',
|
copyrightCreationReprintText: 'Перепечатано из:',
|
||||||
copyrightLicenseText: 'Лицензия:',
|
copyrightLicenseText: 'Лицензия:',
|
||||||
|
|
||||||
|
openNewWindowText: '(Открыть в новой вкладке)',
|
||||||
|
|
||||||
notFound: {
|
notFound: {
|
||||||
code: '404',
|
code: '404',
|
||||||
title: 'Страница не найдена',
|
title: 'Страница не найдена',
|
||||||
|
|||||||
@ -28,6 +28,8 @@ export const zhTwLocale: ThemeLocaleText = {
|
|||||||
copyrightCreationReprintText: '本文轉載自:',
|
copyrightCreationReprintText: '本文轉載自:',
|
||||||
copyrightLicenseText: '授權條款:',
|
copyrightLicenseText: '授權條款:',
|
||||||
|
|
||||||
|
openNewWindowText: '(在新窗口打開)',
|
||||||
|
|
||||||
notFound: {
|
notFound: {
|
||||||
code: '404',
|
code: '404',
|
||||||
title: '頁面未找到',
|
title: '頁面未找到',
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export const zhLocale: ThemeLocaleText = {
|
|||||||
copyrightCreationReprintText: '本文转载自:',
|
copyrightCreationReprintText: '本文转载自:',
|
||||||
copyrightLicenseText: '许可证:',
|
copyrightLicenseText: '许可证:',
|
||||||
|
|
||||||
|
openNewWindowText: '(在新窗口打开)',
|
||||||
|
|
||||||
notFound: {
|
notFound: {
|
||||||
code: '404',
|
code: '404',
|
||||||
title: '页面未找到',
|
title: '页面未找到',
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user