[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"post-\u002Fblog\u002Fvuetify-datepicker-a11y":3},{"id":4,"title":5,"body":6,"category":651,"date":652,"description":653,"draft":654,"extension":655,"meta":656,"navigation":151,"path":657,"readingTime":227,"seo":658,"stem":659,"tags":660,"__hash__":664},"blog\u002Fblog\u002Fvuetify-datepicker-a11y.md","讓 Vuetify 日期選擇器支援鍵盤與螢幕報讀器",{"type":7,"value":8,"toc":643},"minimark",[9,13,16,20,23,75,78,82,90,240,243,247,250,253,266,269,273,276,283,609,616,620,625,628,639],[10,11,12],"p",{},"依無障礙標準改寫 Vue 3 專案時，我遇到一個元件庫解決不了的問題：Vuetify 的日期選擇器，無法完全用鍵盤完成操作，螢幕報讀器的導讀也不完整。",[10,14,15],{},"日期欄位出現在多個表單裡，所以我把處理方式整理成一個共用的 mixin，讓每個頁面不用各寫一次。",[17,18,19],"h2",{"id":19},"先列出使用者需要完成的操作",[10,21,22],{},"與其一個一個修問題，我先列出只用鍵盤的人要怎麼選日期：",[24,25,26,39],"table",{},[27,28,29],"thead",{},[30,31,32,36],"tr",{},[33,34,35],"th",{},"按鍵",[33,37,38],{},"預期行為",[40,41,42,51,59,67],"tbody",{},[30,43,44,48],{},[45,46,47],"td",{},"方向鍵",[45,49,50],{},"左右移動一天，上下移動一週",[30,52,53,56],{},[45,54,55],{},"Enter",[45,57,58],{},"選定日期，並回到輸入框",[30,60,61,64],{},[45,62,63],{},"Escape",[45,65,66],{},"關閉選擇器，並回到輸入框",[30,68,69,72],{},[45,70,71],{},"切換月份或年份時",[45,73,74],{},"焦點移到清單中對應的項目，選完回到日期",[10,76,77],{},"這張表同時也是驗收標準。",[17,79,81],{"id":80},"方向鍵移動的是日期不是-dom","方向鍵：移動的是日期，不是 DOM",[10,83,84,85,89],{},"日期的相鄰關係不該靠找隔壁的按鈕來判斷，因為跨月時隔壁的按鈕可能不存在。把按鍵換算成天數，再交給 ",[86,87,88],"code",{},"Date"," 處理跨月和跨年，邏輯簡單很多：",[91,92,97],"pre",{"className":93,"code":94,"language":95,"meta":96,"style":96},"language-js shiki shiki-themes github-dark","const OFFSETS = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }\n\nfunction moveDate(current, key) {\n  const next = new Date(current)\n  next.setDate(current.getDate() + OFFSETS[key])\n  return next\n}\n","js","",[86,98,99,146,153,179,199,225,234],{"__ignoreMap":96},[100,101,104,108,112,115,119,122,125,128,130,133,135,138,141,143],"span",{"class":102,"line":103},"line",1,[100,105,107],{"class":106},"snl16","const",[100,109,111],{"class":110},"sDLfK"," OFFSETS",[100,113,114],{"class":106}," =",[100,116,118],{"class":117},"s95oV"," { ArrowLeft: ",[100,120,121],{"class":106},"-",[100,123,124],{"class":110},"1",[100,126,127],{"class":117},", ArrowRight: ",[100,129,124],{"class":110},[100,131,132],{"class":117},", ArrowUp: ",[100,134,121],{"class":106},[100,136,137],{"class":110},"7",[100,139,140],{"class":117},", ArrowDown: ",[100,142,137],{"class":110},[100,144,145],{"class":117}," }\n",[100,147,149],{"class":102,"line":148},2,[100,150,152],{"emptyLinePlaceholder":151},true,"\n",[100,154,156,159,163,166,170,173,176],{"class":102,"line":155},3,[100,157,158],{"class":106},"function",[100,160,162],{"class":161},"svObZ"," moveDate",[100,164,165],{"class":117},"(",[100,167,169],{"class":168},"s9osk","current",[100,171,172],{"class":117},", ",[100,174,175],{"class":168},"key",[100,177,178],{"class":117},") {\n",[100,180,182,185,188,190,193,196],{"class":102,"line":181},4,[100,183,184],{"class":106},"  const",[100,186,187],{"class":110}," next",[100,189,114],{"class":106},[100,191,192],{"class":106}," new",[100,194,195],{"class":161}," Date",[100,197,198],{"class":117},"(current)\n",[100,200,202,205,208,211,214,217,220,222],{"class":102,"line":201},5,[100,203,204],{"class":117},"  next.",[100,206,207],{"class":161},"setDate",[100,209,210],{"class":117},"(current.",[100,212,213],{"class":161},"getDate",[100,215,216],{"class":117},"() ",[100,218,219],{"class":106},"+",[100,221,111],{"class":110},[100,223,224],{"class":117},"[key])\n",[100,226,228,231],{"class":102,"line":227},6,[100,229,230],{"class":106},"  return",[100,232,233],{"class":117}," next\n",[100,235,237],{"class":102,"line":236},7,[100,238,239],{"class":117},"}\n",[10,241,242],{},"超出可選範圍的日期不移動，其餘由呼叫方更新畫面並移動焦點。",[17,244,246],{"id":245},"焦點管理畫面換了焦點要跟著走","焦點管理：畫面換了，焦點要跟著走",[10,248,249],{},"這是最花時間的部分。選擇月份後，月份清單會被日期格取代，原本有焦點的按鈕消失，焦點就會掉回頁面開頭，鍵盤使用者等於迷路了。",[10,251,252],{},"所以每次畫面切換，都要明確決定焦點的去向：",[254,255,256,260,263],"ul",{},[257,258,259],"li",{},"開啟月份或年份清單：聚焦到目前日期所在的項目。",[257,261,262],{},"選完月份或年份：回到日期格，依序找相同的日期、已選定的日期、今天，最後才是第一個可用的日期。",[257,264,265],{},"按 Enter 或 Escape 關閉：回到原本的輸入框。",[10,267,268],{},"月份和年份清單的方向鍵也要另外處理，因為它們是多欄排列，上下鍵要跳過一整欄的數量，不是移動一格。",[17,270,272],{"id":271},"報讀標籤用-mutationobserver-補上","報讀標籤：用 MutationObserver 補上",[10,274,275],{},"日期按鈕需要完整的名稱，報讀器才能念出「2026年11月2日 星期一」這樣的內容。但這些按鈕由 Vuetify 產生，換月份時會重新渲染，只在開啟時設定一次是不夠的。",[10,277,278,279,282],{},"我的做法是監看選擇器的變化，只替還沒有標籤的按鈕補上 ",[86,280,281],{},"aria-label","：",[91,284,286],{"className":93,"code":285,"language":95,"meta":96,"style":96},"const WEEK = ['日', '一', '二', '三', '四', '五', '六']\n\nfunction labelDays(picker) {\n  picker.querySelectorAll('.v-date-picker-month__day button').forEach((btn) => {\n    const iso = btn.closest('.v-date-picker-month__day')?.dataset.vDate\n    if (!iso || btn.hasAttribute('aria-label')) return\n    const d = new Date(iso)\n    btn.setAttribute('aria-label', `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${WEEK[d.getDay()]}`)\n  })\n}\n\nconst observer = new MutationObserver(() => labelDays(picker))\nobserver.observe(picker, { childList: true, subtree: true })\n",[86,287,288,337,341,355,389,413,446,462,545,551,556,561,586],{"__ignoreMap":96},[100,289,290,292,295,297,300,304,306,309,311,314,316,319,321,324,326,329,331,334],{"class":102,"line":103},[100,291,107],{"class":106},[100,293,294],{"class":110}," WEEK",[100,296,114],{"class":106},[100,298,299],{"class":117}," [",[100,301,303],{"class":302},"sU2Wk","'日'",[100,305,172],{"class":117},[100,307,308],{"class":302},"'一'",[100,310,172],{"class":117},[100,312,313],{"class":302},"'二'",[100,315,172],{"class":117},[100,317,318],{"class":302},"'三'",[100,320,172],{"class":117},[100,322,323],{"class":302},"'四'",[100,325,172],{"class":117},[100,327,328],{"class":302},"'五'",[100,330,172],{"class":117},[100,332,333],{"class":302},"'六'",[100,335,336],{"class":117},"]\n",[100,338,339],{"class":102,"line":148},[100,340,152],{"emptyLinePlaceholder":151},[100,342,343,345,348,350,353],{"class":102,"line":155},[100,344,158],{"class":106},[100,346,347],{"class":161}," labelDays",[100,349,165],{"class":117},[100,351,352],{"class":168},"picker",[100,354,178],{"class":117},[100,356,357,360,363,365,368,371,374,377,380,383,386],{"class":102,"line":181},[100,358,359],{"class":117},"  picker.",[100,361,362],{"class":161},"querySelectorAll",[100,364,165],{"class":117},[100,366,367],{"class":302},"'.v-date-picker-month__day button'",[100,369,370],{"class":117},").",[100,372,373],{"class":161},"forEach",[100,375,376],{"class":117},"((",[100,378,379],{"class":168},"btn",[100,381,382],{"class":117},") ",[100,384,385],{"class":106},"=>",[100,387,388],{"class":117}," {\n",[100,390,391,394,397,399,402,405,407,410],{"class":102,"line":201},[100,392,393],{"class":106},"    const",[100,395,396],{"class":110}," iso",[100,398,114],{"class":106},[100,400,401],{"class":117}," btn.",[100,403,404],{"class":161},"closest",[100,406,165],{"class":117},[100,408,409],{"class":302},"'.v-date-picker-month__day'",[100,411,412],{"class":117},")?.dataset.vDate\n",[100,414,415,418,421,424,427,430,432,435,437,440,443],{"class":102,"line":227},[100,416,417],{"class":106},"    if",[100,419,420],{"class":117}," (",[100,422,423],{"class":106},"!",[100,425,426],{"class":117},"iso ",[100,428,429],{"class":106},"||",[100,431,401],{"class":117},[100,433,434],{"class":161},"hasAttribute",[100,436,165],{"class":117},[100,438,439],{"class":302},"'aria-label'",[100,441,442],{"class":117},")) ",[100,444,445],{"class":106},"return\n",[100,447,448,450,453,455,457,459],{"class":102,"line":236},[100,449,393],{"class":106},[100,451,452],{"class":110}," d",[100,454,114],{"class":106},[100,456,192],{"class":106},[100,458,195],{"class":161},[100,460,461],{"class":117},"(iso)\n",[100,463,465,468,471,473,475,477,480,483,486,489,492,495,497,499,502,504,506,509,512,514,516,518,520,523,526,529,531,533,536,539,542],{"class":102,"line":464},8,[100,466,467],{"class":117},"    btn.",[100,469,470],{"class":161},"setAttribute",[100,472,165],{"class":117},[100,474,439],{"class":302},[100,476,172],{"class":117},[100,478,479],{"class":302},"`${",[100,481,482],{"class":117},"d",[100,484,485],{"class":302},".",[100,487,488],{"class":161},"getFullYear",[100,490,491],{"class":302},"()",[100,493,494],{"class":302},"}年${",[100,496,482],{"class":117},[100,498,485],{"class":302},[100,500,501],{"class":161},"getMonth",[100,503,216],{"class":302},[100,505,219],{"class":106},[100,507,508],{"class":110}," 1",[100,510,511],{"class":302},"}月${",[100,513,482],{"class":117},[100,515,485],{"class":302},[100,517,213],{"class":161},[100,519,491],{"class":302},[100,521,522],{"class":302},"}日 星期${",[100,524,525],{"class":110},"WEEK",[100,527,528],{"class":302},"[",[100,530,482],{"class":117},[100,532,485],{"class":302},[100,534,535],{"class":161},"getDay",[100,537,538],{"class":302},"()]",[100,540,541],{"class":302},"}`",[100,543,544],{"class":117},")\n",[100,546,548],{"class":102,"line":547},9,[100,549,550],{"class":117},"  })\n",[100,552,554],{"class":102,"line":553},10,[100,555,239],{"class":117},[100,557,559],{"class":102,"line":558},11,[100,560,152],{"emptyLinePlaceholder":151},[100,562,564,566,569,571,573,576,579,581,583],{"class":102,"line":563},12,[100,565,107],{"class":106},[100,567,568],{"class":110}," observer",[100,570,114],{"class":106},[100,572,192],{"class":106},[100,574,575],{"class":161}," MutationObserver",[100,577,578],{"class":117},"(() ",[100,580,385],{"class":106},[100,582,347],{"class":161},[100,584,585],{"class":117},"(picker))\n",[100,587,589,592,595,598,601,604,606],{"class":102,"line":588},13,[100,590,591],{"class":117},"observer.",[100,593,594],{"class":161},"observe",[100,596,597],{"class":117},"(picker, { childList: ",[100,599,600],{"class":110},"true",[100,602,603],{"class":117},", subtree: ",[100,605,600],{"class":110},[100,607,608],{"class":117}," })\n",[10,610,611,612,615],{},"選擇器關閉時要記得 ",[86,613,614],{},"disconnect()","，否則觀察器會留在記憶體裡。「只補沒有標籤的按鈕」也不是多餘的判斷：這個回呼自己也會修改 DOM，如果每次都重寫，就容易造成不必要的重複處理。",[17,617,619],{"id":618},"用-nvda-驗證不要只看程式碼","用 NVDA 驗證，不要只看程式碼",[10,621,622,624],{},[86,623,281],{}," 寫了不代表念得對。修改後我實際開啟 NVDA，只用鍵盤走完整個流程：開啟選擇器、移動日期、切換月份和年份、選定、關閉，確認每一步的焦點位置和報讀內容都符合預期。",[17,626,627],{"id":627},"這次學到的事",[254,629,630,633,636],{},[257,631,632],{},"元件庫不等於無障礙。它能省下很多工，但實際走過流程之前，不能假設它都處理好了。",[257,634,635],{},"無障礙不只是加屬性，焦點管理才是鍵盤操作體驗的核心。",[257,637,638],{},"這個做法依賴 Vuetify 的 class 名稱和 DOM 結構，升級版本時可能失效，所以每次升級都要重新驗證。",[640,641,642],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":96,"searchDepth":148,"depth":148,"links":644},[645,646,647,648,649,650],{"id":19,"depth":148,"text":19},{"id":80,"depth":148,"text":81},{"id":245,"depth":148,"text":246},{"id":271,"depth":148,"text":272},{"id":618,"depth":148,"text":619},{"id":627,"depth":148,"text":627},"quality","2026-04-22","無障礙改寫時遇到元件庫支援不足的日期選擇器，如何補上鍵盤操作、焦點管理與報讀標籤，並用 NVDA 驗證。",false,"md",{},"\u002Fblog\u002Fvuetify-datepicker-a11y",{"title":5,"description":653},"blog\u002Fvuetify-datepicker-a11y",[661,662,663],"Accessibility","Vuetify","NVDA","VQ1s6P8wh_0ZD0ZvUheSRBB2Z8d_dbmyUnVIBPztjh8"]