Commit 08e3a6c

HPCesia <me@hpcesia.com>
2025-04-16 06:39:33
fix(toolbar): improve a11y of side toolbar
1 parent 6ee17f9
src/components/widgets/SideToolBar/TocButton.vue
@@ -1,4 +1,6 @@
 <script setup lang="ts">
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
 import { onMounted, onUnmounted, ref } from 'vue';
 
 const isOpen = ref(false);
@@ -63,12 +65,14 @@ onUnmounted(() => {
       ref="buttonRef"
       class="btn btn-circle btn-secondary btn-sm"
       @click="isOpen = !isOpen"
+      :title="i18n(I18nKey.toc)"
     >
       <slot name="icon" />
     </button>
     <div
       ref="tocWrapper"
       class="rounded-box absolute w-[calc(100vw-4rem)] -translate-x-1/2 -translate-y-1/2 max-w-72 backdrop-blur-md duration-300 text-base-content text-start"
+      :inert="!isOpen || isWideScreen"
       :class="{
         '-translate-x-[calc(100%+0.5rem)]! -translate-y-[calc(100%-2.5rem)]!':
           isOpen && !isWideScreen,
src/components/widgets/DarkModeButton.astro
@@ -13,20 +13,34 @@ const { class: className, showText, ...rest } = Astro.props;
 ---
 
 <Button
-  class:list={['darkmode-btn swap', className]}
+  class:list={['darkmode-btn swap swap-rotate', className]}
   {...rest}
   data-text-light={i18n(I18nKey.lightMode)}
   data-text-dark={i18n(I18nKey.darkMode)}
   data-text-auto={i18n(I18nKey.systemMode)}
+  role="switch"
+  aria-label={i18n(I18nKey.themeToggle)}
 >
-  <input type="checkbox" />
-  <Icon class="darkmode-icon-light swap-off" name="material-symbols:light-mode-rounded" />
-  <Icon class="darkmode-icon-dark swap-on" name="material-symbols:dark-mode-rounded" />
+  <input type="checkbox" inert />
+  <Icon
+    class="darkmode-icon-light swap-off"
+    name="material-symbols:light-mode-rounded"
+    role="presentation"
+    aria-hidden
+  />
+  <Icon
+    class="darkmode-icon-dark swap-on"
+    name="material-symbols:dark-mode-rounded"
+    role="presentation"
+    aria-hidden
+  />
   <Icon
     class="darkmode-icon-auto swap-indeterminate"
     name="material-symbols:night-sight-auto-rounded"
+    role="presentation"
+    aria-hidden
   />
-  {showText && <span class="darkmode-text pl-6" />}
+  <span class={showText ? 'darkmode-text pl-6' : 'sr-only'}></span>
 </Button>
 
 <script>
@@ -57,7 +71,7 @@ const { class: className, showText, ...rest } = Astro.props;
 
     darkmodeBtns.forEach((btn) => {
       const checkbox = btn.querySelector('input[type="checkbox"]') as HTMLInputElement;
-      const text = btn.querySelector('.darkmode-text');
+      const text = btn.querySelector('.darkmode-text') || btn.querySelector('.sr-only');
 
       // 更新UI状态和文本
       function updateUI(mode: 'system' | 'light' | 'dark') {
@@ -65,19 +79,20 @@ const { class: className, showText, ...rest } = Astro.props;
           checkbox.indeterminate = true;
         } else {
           checkbox.indeterminate = false;
-          checkbox.checked = mode === 'dark';
         }
+        checkbox.checked = mode === 'dark';
 
-        if (text) {
-          const textContent =
-            mode === 'system'
-              ? btn.getAttribute('data-text-auto')
-              : mode === 'dark'
-                ? btn.getAttribute('data-text-dark')
-                : btn.getAttribute('data-text-light');
+        const textContent =
+          mode === 'system'
+            ? btn.getAttribute('data-text-auto')
+            : mode === 'dark'
+              ? btn.getAttribute('data-text-dark')
+              : btn.getAttribute('data-text-light');
+
+        btn.setAttribute('title', textContent || '');
 
+        if (text) {
           text.textContent = textContent;
-          btn.setAttribute('title', textContent || '');
         }
       }
 
src/components/SideToolBar.astro
@@ -1,5 +1,7 @@
 ---
 import { articleConfig, toolBarConfig } from '@/config';
+import I18nKey from '@i18n/I18nKey';
+import { i18n } from '@i18n/translation';
 import { Icon } from 'astro-icon/components';
 import Button from './widgets/Button.astro';
 import DarkModeButton from './widgets/DarkModeButton.astro';
@@ -7,40 +9,10 @@ import TocButton from './widgets/SideToolBar/TocButton.vue';
 ---
 
 <div id="side-toolbar" class="fixed right-0 bottom-10 z-30 grid grid-cols-1 gap-2">
-  <div
-    id="stb-show"
-    class="peer order-2 grid translate-x-full grid-cols-1 gap-2 pr-4 duration-500 ease-in-out"
-  >
-    <Button id="stb-show-more" class="btn-circle btn-secondary btn-sm">
-      <input
-        type="checkbox"
-        class="absolute z-10 h-8 w-8 cursor-pointer appearance-none border-0"
-        checked
-      />
-      <Icon name="material-symbols:settings-rounded" class="animate-spin" />
-    </Button>
-    {
-      articleConfig.toc && (
-        <TocButton client:media="(width <= 80rem)">
-          <Icon name="material-symbols:toc-rounded" slot="icon" />
-        </TocButton>
-      )
-    }
-    <Button id="stb-back-to-top" class="group btn-circle btn-secondary btn-sm">
-      <span
-        id="stb-read-percentage"
-        class="absolute text-sm opacity-0 duration-300 group-hover:opacity-0">0</span
-      >
-      <Icon
-        id="stb-back-to-top-icon"
-        name="material-symbols:arrow-upward-rounded"
-        class="duration-300 group-hover:opacity-100"
-      />
-    </Button>
-  </div>
   <div
     id="stb-hide"
-    class="order-1 grid grid-cols-1 gap-2 pr-4 duration-500 ease-in-out peer-[:first-child:has(:checked)]:translate-x-full"
+    class="grid translate-x-full grid-cols-1 gap-2 pr-4 duration-500 ease-in-out"
+    inert
   >
     {
       toolBarConfig.items.map((item) => {
@@ -73,6 +45,38 @@ import TocButton from './widgets/SideToolBar/TocButton.vue';
     }
     <DarkModeButton id="stb-dark-mode" class="btn-circle btn-secondary btn-sm" />
   </div>
+  <div
+    id="stb-show"
+    class="grid translate-x-full grid-cols-1 gap-2 pr-4 duration-500 ease-in-out"
+  >
+    <Button
+      id="stb-show-more"
+      class="btn-circle btn-secondary btn-sm"
+      aria-expanded="false"
+      aria-label={i18n(I18nKey.more)}
+      aria-controls="stb-hide"
+    >
+      <Icon name="material-symbols:settings-rounded" class="animate-spin" />
+    </Button>
+    {
+      articleConfig.toc && (
+        <TocButton client:media="(width <= 80rem)">
+          <Icon name="material-symbols:toc-rounded" slot="icon" />
+        </TocButton>
+      )
+    }
+    <Button id="stb-back-to-top" class="group btn-circle btn-secondary btn-sm">
+      <span
+        id="stb-read-percentage"
+        class="absolute text-sm opacity-0 duration-300 group-hover:opacity-0">0</span
+      >
+      <Icon
+        id="stb-back-to-top-icon"
+        name="material-symbols:arrow-upward-rounded"
+        class="duration-300 group-hover:opacity-100"
+      />
+    </Button>
+  </div>
 </div>
 
 <script>
@@ -83,11 +87,26 @@ import TocButton from './widgets/SideToolBar/TocButton.vue';
 
   function setup() {
     const stbShow = document.getElementById('stb-show');
+    const stbHide = document.getElementById('stb-hide');
     const stbShowMore = document.getElementById('stb-show-more');
     const stbBackToTop = document.getElementById('stb-back-to-top');
     const stbBackToTopIcon = document.getElementById('stb-back-to-top-icon');
     const stbReadPercent = document.getElementById('stb-read-percentage');
 
+    let isExpanded = JSON.parse(stbShowMore?.getAttribute('aria-expanded') || 'false');
+
+    stbShowMore?.addEventListener('click', () => {
+      isExpanded = !isExpanded;
+      stbShowMore.setAttribute('aria-expanded', String(isExpanded));
+      if (isExpanded) {
+        stbHide?.classList.remove('translate-x-full');
+        stbHide?.removeAttribute('inert');
+      } else {
+        stbHide?.classList.add('translate-x-full');
+        stbHide?.setAttribute('inert', 'true');
+      }
+    });
+
     stbBackToTop?.addEventListener('click', () => {
       window.scrollTo({
         top: 0,
@@ -108,7 +127,9 @@ import TocButton from './widgets/SideToolBar/TocButton.vue';
         stbShow?.classList.remove('translate-x-full');
       } else {
         stbShow?.classList.add('translate-x-full');
-        stbShowMore!.querySelector('input')!.checked = true;
+        stbShowMore?.setAttribute('aria-expanded', 'false');
+        isExpanded = false;
+        document.getElementById('stb-hide')?.classList.add('translate-x-full');
       }
       // 控制进度条
       const scrolledPercentage = getReadingProgress(bottomPos);
src/i18n/langs/en.ts
@@ -39,6 +39,9 @@ export const en: Translation = {
   [Key.categoryCount]: 'category',
   [Key.categoriesCount]: 'categories',
 
+  [Key.toc]: 'Table of Content',
+
+  [Key.themeToggle]: 'Toggle Theme',
   [Key.lightMode]: 'Light',
   [Key.darkMode]: 'Dark',
   [Key.systemMode]: 'System',
src/i18n/langs/zh_CN.ts
@@ -39,6 +39,9 @@ export const zh_CN: Translation = {
   [Key.categoryCount]: '个分类',
   [Key.categoriesCount]: '个分类',
 
+  [Key.toc]: '目录',
+
+  [Key.themeToggle]: '主题切换',
   [Key.lightMode]: '亮色',
   [Key.darkMode]: '暗色',
   [Key.systemMode]: '跟随系统',
src/i18n/langs/zh_TW.ts
@@ -39,6 +39,9 @@ export const zh_TW: Translation = {
   [Key.categoryCount]: '個分類',
   [Key.categoriesCount]: '個分類',
 
+  [Key.toc]: '目錄',
+
+  [Key.themeToggle]: '主題切換',
   [Key.lightMode]: '亮色',
   [Key.darkMode]: '暗色',
   [Key.systemMode]: '跟隨系統',
src/i18n/I18nKey.ts
@@ -36,6 +36,9 @@ enum I18nKey {
   categoryCount = 'categoryCount',
   categoriesCount = 'categoriesCount',
 
+  toc = 'toc',
+
+  themeToggle = 'themeToggle',
   lightMode = 'lightMode',
   darkMode = 'darkMode',
   systemMode = 'systemMode',