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