[{"data":1,"prerenderedAt":1460},["ShallowReactive",2],{"layout-sidebar-\u002Fblog\u002Fkeyboard-navigation-composite-widgets":3,"blog-keyboard-navigation-composite-widgets":45},{"type":4,"author":5,"date":6,"status":7,"toc":8},"blog-detail","Fabian Kirchhoff","2026-05-10","published",[9,13,16,20,23,26,28,30,33,36,39,42],{"id":10,"text":11,"depth":12},"the-problem-tab-key-overload","The Problem: Tab Key Overload",2,{"id":14,"text":15,"depth":12},"pattern-1-roving-tabindex","Pattern 1: Roving Tabindex",{"id":17,"text":18,"depth":19},"how-it-works","How It Works",3,{"id":21,"text":22,"depth":19},"implementation","Implementation",{"id":24,"text":25,"depth":12},"pattern-2-aria-activedescendant","Pattern 2: aria-activedescendant",{"id":27,"text":18,"depth":19},"how-it-works-1",{"id":29,"text":22,"depth":19},"implementation-1",{"id":31,"text":32,"depth":12},"when-to-use-which","When to Use Which",{"id":34,"text":35,"depth":19},"decision-rule","Decision Rule",{"id":37,"text":38,"depth":12},"common-pitfalls","Common Pitfalls",{"id":40,"text":41,"depth":12},"resources","Resources",{"id":43,"text":44,"depth":12},"related-posts","Related Posts",{"id":46,"title":47,"author":5,"body":48,"date":6,"description":1446,"extension":1447,"featured":1448,"meta":1449,"navigation":250,"order":1450,"path":1451,"seo":1452,"specs":1453,"status":7,"stem":1457,"tag":1458,"__hash__":1459},"blog\u002Fblog\u002Fkeyboard-navigation-composite-widgets.md","Keyboard Navigation in Composite Widgets",{"type":49,"value":50,"toc":1429},"minimark",[51,55,68,71,74,77,80,114,117,119,138,141,144,147,176,182,184,190,605,745,748,750,764,767,770,772,800,803,805,808,1021,1152,1168,1170,1269,1271,1274,1303,1305,1380,1382,1414,1416,1425],[52,53,47],"h1",{"id":54},"keyboard-navigation-in-composite-widgets",[56,57,58,59,63,64,67],"p",{},"Composite widgets — tab bars, toolbars, listboxes, comboboxes — contain multiple interactive elements but should behave as a single tab stop. Two W3C patterns handle this: ",[60,61,62],"strong",{},"roving tabindex"," and ",[60,65,66],{},"aria-activedescendant",". They solve the same problem differently, and picking the wrong one makes the widget harder to use.",[56,69,70],{},"This post walks through both patterns with interactive demos and production implementation details.",[72,73,11],"h2",{"id":10},[56,75,76],{},"Imagine a tab bar with 8 tabs where each tab is a separate tab stop. A keyboard user must press Tab 8 times just to get past it. For a toolbar with 12 buttons, that's 12 Tab presses before reaching the main content. This is a terrible experience.",[56,78,79],{},"Composite widgets should work like this:",[81,82,83,98,108],"ul",{},[84,85,86,89,90,63,94,97],"li",{},[60,87,88],{},"Tab"," moves focus ",[91,92,93],"em",{},"into",[91,95,96],{},"out of"," the widget (one tab stop)",[84,99,100,103,104,107],{},[60,101,102],{},"Arrow keys"," navigate between items ",[91,105,106],{},"within"," the widget",[84,109,110,113],{},[60,111,112],{},"Enter\u002FSpace"," activates the focused item",[56,115,116],{},"This is the pattern every native OS widget follows. Both roving tabindex and aria-activedescendant achieve it — but through different mechanisms.",[72,118,15],{"id":14},[56,120,121,122,126,127,130,131,133,134,137],{},"Only one item in the group has ",[123,124,125],"code",{},"tabindex=\"0\""," at any given time. All other items have ",[123,128,129],{},"tabindex=\"-1\"",". When arrow keys are pressed, ",[123,132,125],{}," moves to the new target and ",[123,135,136],{},".focus()"," is called on it. Focus actually moves in the DOM.",[139,140],"roving-tab-demo",{},[56,142,143],{},"Try it: use Tab to enter the widget, then Arrow keys to move between items. Tab again to leave. Notice how focus visibly moves from one item to the next — that's real DOM focus shifting.",[145,146,18],"h3",{"id":17},[148,149,150,158,167,173],"ol",{},[84,151,152,153,155,156],{},"One element gets ",[123,154,125],{},", the rest get ",[123,157,129],{},[84,159,160,161,163,164,166],{},"Arrow keys update which element has ",[123,162,125],{}," and call ",[123,165,136],{}," on it",[84,168,169,170,172],{},"Tab leaves the widget entirely. Shift+Tab re-enters at the last focused item (because it still has ",[123,171,125],{},")",[84,174,175],{},"Home\u002FEnd jump to first\u002Flast item",[56,177,178,179,181],{},"When the user tabs away and later tabs back, the previously focused item still has ",[123,180,125],{}," — focus returns right where they left off.",[145,183,22],{"id":21},[56,185,186,187,189],{},"The core logic tracks which tab holds ",[123,188,125],{}," (keyboard focus) separately from which tab is active (displayed panel):",[191,192,197],"pre",{"className":193,"code":194,"language":195,"meta":196,"style":196},"language-typescript shiki shiki-themes github-dark github-dark","const focusedTab = ref(tabs[0])\nconst activeTab = ref(tabs[0])\n\nfunction handleKeydown(event: KeyboardEvent) {\n  const idx = tabs.indexOf(focusedTab.value)\n  let next: number | null = null\n\n  switch (event.key) {\n    case 'ArrowRight':\n      next = (idx + 1) % tabs.length\n      break\n    case 'ArrowLeft':\n      next = (idx - 1 + tabs.length) % tabs.length\n      break\n    case 'Home':\n      next = 0\n      break\n    case 'End':\n      next = tabs.length - 1\n      break\n    case 'Enter':\n    case ' ':\n      event.preventDefault()\n      activeTab.value = focusedTab.value\n      return\n  }\n\n  if (next !== null) {\n    event.preventDefault()\n    focusedTab.value = tabs[next]\n    tabRefs[next].focus()\n  }\n}\n","typescript","",[123,198,199,229,246,252,277,297,322,327,336,349,378,384,394,424,429,439,449,454,464,481,486,496,506,518,529,535,541,546,562,572,583,594,599],{"__ignoreMap":196},[200,201,204,208,212,215,219,223,226],"span",{"class":202,"line":203},"line",1,[200,205,207],{"class":206},"sOPea","const",[200,209,211],{"class":210},"s8ozJ"," focusedTab",[200,213,214],{"class":206}," =",[200,216,218],{"class":217},"sFR8T"," ref",[200,220,222],{"class":221},"suv1-","(tabs[",[200,224,225],{"class":210},"0",[200,227,228],{"class":221},"])\n",[200,230,231,233,236,238,240,242,244],{"class":202,"line":12},[200,232,207],{"class":206},[200,234,235],{"class":210}," activeTab",[200,237,214],{"class":206},[200,239,218],{"class":217},[200,241,222],{"class":221},[200,243,225],{"class":210},[200,245,228],{"class":221},[200,247,248],{"class":202,"line":19},[200,249,251],{"emptyLinePlaceholder":250},true,"\n",[200,253,255,258,261,264,268,271,274],{"class":202,"line":254},4,[200,256,257],{"class":206},"function",[200,259,260],{"class":217}," handleKeydown",[200,262,263],{"class":221},"(",[200,265,267],{"class":266},"s-3mD","event",[200,269,270],{"class":206},":",[200,272,273],{"class":217}," KeyboardEvent",[200,275,276],{"class":221},") {\n",[200,278,280,283,286,288,291,294],{"class":202,"line":279},5,[200,281,282],{"class":206},"  const",[200,284,285],{"class":210}," idx",[200,287,214],{"class":206},[200,289,290],{"class":221}," tabs.",[200,292,293],{"class":217},"indexOf",[200,295,296],{"class":221},"(focusedTab.value)\n",[200,298,300,303,306,308,311,314,317,319],{"class":202,"line":299},6,[200,301,302],{"class":206},"  let",[200,304,305],{"class":221}," next",[200,307,270],{"class":206},[200,309,310],{"class":210}," number",[200,312,313],{"class":206}," |",[200,315,316],{"class":210}," null",[200,318,214],{"class":206},[200,320,321],{"class":210}," null\n",[200,323,325],{"class":202,"line":324},7,[200,326,251],{"emptyLinePlaceholder":250},[200,328,330,333],{"class":202,"line":329},8,[200,331,332],{"class":206},"  switch",[200,334,335],{"class":221}," (event.key) {\n",[200,337,339,342,346],{"class":202,"line":338},9,[200,340,341],{"class":206},"    case",[200,343,345],{"class":344},"s4wv1"," 'ArrowRight'",[200,347,348],{"class":221},":\n",[200,350,352,355,358,361,364,367,370,373,375],{"class":202,"line":351},10,[200,353,354],{"class":221},"      next ",[200,356,357],{"class":206},"=",[200,359,360],{"class":221}," (idx ",[200,362,363],{"class":206},"+",[200,365,366],{"class":210}," 1",[200,368,369],{"class":221},") ",[200,371,372],{"class":206},"%",[200,374,290],{"class":221},[200,376,377],{"class":210},"length\n",[200,379,381],{"class":202,"line":380},11,[200,382,383],{"class":206},"      break\n",[200,385,387,389,392],{"class":202,"line":386},12,[200,388,341],{"class":206},[200,390,391],{"class":344}," 'ArrowLeft'",[200,393,348],{"class":221},[200,395,397,399,401,403,406,408,411,413,416,418,420,422],{"class":202,"line":396},13,[200,398,354],{"class":221},[200,400,357],{"class":206},[200,402,360],{"class":221},[200,404,405],{"class":206},"-",[200,407,366],{"class":210},[200,409,410],{"class":206}," +",[200,412,290],{"class":221},[200,414,415],{"class":210},"length",[200,417,369],{"class":221},[200,419,372],{"class":206},[200,421,290],{"class":221},[200,423,377],{"class":210},[200,425,427],{"class":202,"line":426},14,[200,428,383],{"class":206},[200,430,432,434,437],{"class":202,"line":431},15,[200,433,341],{"class":206},[200,435,436],{"class":344}," 'Home'",[200,438,348],{"class":221},[200,440,442,444,446],{"class":202,"line":441},16,[200,443,354],{"class":221},[200,445,357],{"class":206},[200,447,448],{"class":210}," 0\n",[200,450,452],{"class":202,"line":451},17,[200,453,383],{"class":206},[200,455,457,459,462],{"class":202,"line":456},18,[200,458,341],{"class":206},[200,460,461],{"class":344}," 'End'",[200,463,348],{"class":221},[200,465,467,469,471,473,475,478],{"class":202,"line":466},19,[200,468,354],{"class":221},[200,470,357],{"class":206},[200,472,290],{"class":221},[200,474,415],{"class":210},[200,476,477],{"class":206}," -",[200,479,480],{"class":210}," 1\n",[200,482,484],{"class":202,"line":483},20,[200,485,383],{"class":206},[200,487,489,491,494],{"class":202,"line":488},21,[200,490,341],{"class":206},[200,492,493],{"class":344}," 'Enter'",[200,495,348],{"class":221},[200,497,499,501,504],{"class":202,"line":498},22,[200,500,341],{"class":206},[200,502,503],{"class":344}," ' '",[200,505,348],{"class":221},[200,507,509,512,515],{"class":202,"line":508},23,[200,510,511],{"class":221},"      event.",[200,513,514],{"class":217},"preventDefault",[200,516,517],{"class":221},"()\n",[200,519,521,524,526],{"class":202,"line":520},24,[200,522,523],{"class":221},"      activeTab.value ",[200,525,357],{"class":206},[200,527,528],{"class":221}," focusedTab.value\n",[200,530,532],{"class":202,"line":531},25,[200,533,534],{"class":206},"      return\n",[200,536,538],{"class":202,"line":537},26,[200,539,540],{"class":221},"  }\n",[200,542,544],{"class":202,"line":543},27,[200,545,251],{"emptyLinePlaceholder":250},[200,547,549,552,555,558,560],{"class":202,"line":548},28,[200,550,551],{"class":206},"  if",[200,553,554],{"class":221}," (next ",[200,556,557],{"class":206},"!==",[200,559,316],{"class":210},[200,561,276],{"class":221},[200,563,565,568,570],{"class":202,"line":564},29,[200,566,567],{"class":221},"    event.",[200,569,514],{"class":217},[200,571,517],{"class":221},[200,573,575,578,580],{"class":202,"line":574},30,[200,576,577],{"class":221},"    focusedTab.value ",[200,579,357],{"class":206},[200,581,582],{"class":221}," tabs[next]\n",[200,584,586,589,592],{"class":202,"line":585},31,[200,587,588],{"class":221},"    tabRefs[next].",[200,590,591],{"class":217},"focus",[200,593,517],{"class":221},[200,595,597],{"class":202,"line":596},32,[200,598,540],{"class":221},[200,600,602],{"class":202,"line":601},33,[200,603,604],{"class":221},"}\n",[191,606,610],{"className":607,"code":608,"language":609,"meta":196,"style":196},"language-vue shiki shiki-themes github-dark github-dark","\u003Cbutton\n  v-for=\"tab in tabs\"\n  role=\"tab\"\n  :aria-selected=\"tab === activeTab\"\n  :tabindex=\"tab === focusedTab ? 0 : -1\"\n  @keydown=\"handleKeydown\"\n>\n  {{ tab.label }}\n\u003C\u002Fbutton>\n","vue",[123,611,612,621,643,653,674,708,725,730,735],{"__ignoreMap":196},[200,613,614,617],{"class":202,"line":203},[200,615,616],{"class":221},"\u003C",[200,618,620],{"class":619},"sxg3X","button\n",[200,622,623,626,628,631,634,637,640],{"class":202,"line":12},[200,624,625],{"class":206},"  v-for",[200,627,357],{"class":221},[200,629,630],{"class":344},"\"",[200,632,633],{"class":221},"tab ",[200,635,636],{"class":206},"in",[200,638,639],{"class":221}," tabs",[200,641,642],{"class":344},"\"\n",[200,644,645,648,650],{"class":202,"line":19},[200,646,647],{"class":217},"  role",[200,649,357],{"class":221},[200,651,652],{"class":344},"\"tab\"\n",[200,654,655,658,661,663,665,667,670,672],{"class":202,"line":254},[200,656,657],{"class":221},"  :",[200,659,660],{"class":217},"aria-selected",[200,662,357],{"class":221},[200,664,630],{"class":344},[200,666,633],{"class":221},[200,668,669],{"class":206},"===",[200,671,235],{"class":221},[200,673,642],{"class":344},[200,675,676,678,681,683,685,687,689,692,695,698,701,703,706],{"class":202,"line":279},[200,677,657],{"class":221},[200,679,680],{"class":217},"tabindex",[200,682,357],{"class":221},[200,684,630],{"class":344},[200,686,633],{"class":221},[200,688,669],{"class":206},[200,690,691],{"class":221}," focusedTab ",[200,693,694],{"class":206},"?",[200,696,697],{"class":210}," 0",[200,699,700],{"class":206}," :",[200,702,477],{"class":206},[200,704,705],{"class":210},"1",[200,707,642],{"class":344},[200,709,710,713,716,718,720,723],{"class":202,"line":299},[200,711,712],{"class":221},"  @",[200,714,715],{"class":217},"keydown",[200,717,357],{"class":221},[200,719,630],{"class":344},[200,721,722],{"class":221},"handleKeydown",[200,724,642],{"class":344},[200,726,727],{"class":202,"line":324},[200,728,729],{"class":221},">\n",[200,731,732],{"class":202,"line":329},[200,733,734],{"class":221},"  {{ tab.label }}\n",[200,736,737,740,743],{"class":202,"line":338},[200,738,739],{"class":221},"\u003C\u002F",[200,741,742],{"class":619},"button",[200,744,729],{"class":221},[56,746,747],{},"Circular wrapping (ArrowRight on last goes to first) matches the W3C Tabs pattern recommendation.",[72,749,25],{"id":24},[56,751,752,753,756,757,759,760,763],{},"Focus stays on a container element — usually an ",[123,754,755],{},"\u003Cinput>",". The container's ",[123,758,66],{}," attribute points to the ",[123,761,762],{},"id"," of the currently \"active\" option. The screen reader announces the referenced element even though it never receives DOM focus.",[765,766],"aria-active-descendant-demo",{},[56,768,769],{},"Try it: focus the input, then use Arrow keys. Notice the input stays focused the whole time — you can still type while navigating. The highlighted item changes visually, and screen readers announce it, but DOM focus never leaves the input.",[145,771,18],{"id":27},[148,773,774,781,789,792,797],{},[84,775,776,777,780],{},"The container (input or div with ",[123,778,779],{},"role=\"combobox\"",") keeps DOM focus at all times",[84,782,783,784,786,787],{},"Arrow keys update ",[123,785,66],{}," to reference a different option's ",[123,788,762],{},[84,790,791],{},"The referenced option is visually highlighted and announced by screen readers",[84,793,794,795],{},"Option elements never receive ",[123,796,136],{},[84,798,799],{},"Because focus stays on the input, it remains editable while navigating options",[56,801,802],{},"This is what makes searchable comboboxes possible. With roving tabindex, moving focus to an option would pull focus out of the input — you'd lose your cursor position and stop being able to type.",[145,804,22],{"id":29},[56,806,807],{},"The input keeps focus and manages everything through attribute updates:",[191,809,811],{"className":193,"code":810,"language":195,"meta":196,"style":196},"const activeIndex = ref(0)\n\nconst activeDescendant = computed(() =>\n  options[activeIndex.value]?.id\n)\n\nfunction handleKeydown(event: KeyboardEvent) {\n  switch (event.key) {\n    case 'ArrowDown':\n      event.preventDefault()\n      activeIndex.value = Math.min(activeIndex.value + 1, options.length - 1)\n      break\n    case 'ArrowUp':\n      event.preventDefault()\n      activeIndex.value = Math.max(activeIndex.value - 1, 0)\n      break\n    case 'Enter':\n      event.preventDefault()\n      selectOption(options[activeIndex.value])\n      break\n  }\n}\n",[123,812,813,831,835,853,858,862,866,882,888,897,905,936,940,949,957,981,985,993,1001,1009,1013,1017],{"__ignoreMap":196},[200,814,815,817,820,822,824,826,828],{"class":202,"line":203},[200,816,207],{"class":206},[200,818,819],{"class":210}," activeIndex",[200,821,214],{"class":206},[200,823,218],{"class":217},[200,825,263],{"class":221},[200,827,225],{"class":210},[200,829,830],{"class":221},")\n",[200,832,833],{"class":202,"line":12},[200,834,251],{"emptyLinePlaceholder":250},[200,836,837,839,842,844,847,850],{"class":202,"line":19},[200,838,207],{"class":206},[200,840,841],{"class":210}," activeDescendant",[200,843,214],{"class":206},[200,845,846],{"class":217}," computed",[200,848,849],{"class":221},"(() ",[200,851,852],{"class":206},"=>\n",[200,854,855],{"class":202,"line":254},[200,856,857],{"class":221},"  options[activeIndex.value]?.id\n",[200,859,860],{"class":202,"line":279},[200,861,830],{"class":221},[200,863,864],{"class":202,"line":299},[200,865,251],{"emptyLinePlaceholder":250},[200,867,868,870,872,874,876,878,880],{"class":202,"line":324},[200,869,257],{"class":206},[200,871,260],{"class":217},[200,873,263],{"class":221},[200,875,267],{"class":266},[200,877,270],{"class":206},[200,879,273],{"class":217},[200,881,276],{"class":221},[200,883,884,886],{"class":202,"line":329},[200,885,332],{"class":206},[200,887,335],{"class":221},[200,889,890,892,895],{"class":202,"line":338},[200,891,341],{"class":206},[200,893,894],{"class":344}," 'ArrowDown'",[200,896,348],{"class":221},[200,898,899,901,903],{"class":202,"line":351},[200,900,511],{"class":221},[200,902,514],{"class":217},[200,904,517],{"class":221},[200,906,907,910,912,915,918,921,923,925,928,930,932,934],{"class":202,"line":380},[200,908,909],{"class":221},"      activeIndex.value ",[200,911,357],{"class":206},[200,913,914],{"class":221}," Math.",[200,916,917],{"class":217},"min",[200,919,920],{"class":221},"(activeIndex.value ",[200,922,363],{"class":206},[200,924,366],{"class":210},[200,926,927],{"class":221},", options.",[200,929,415],{"class":210},[200,931,477],{"class":206},[200,933,366],{"class":210},[200,935,830],{"class":221},[200,937,938],{"class":202,"line":386},[200,939,383],{"class":206},[200,941,942,944,947],{"class":202,"line":396},[200,943,341],{"class":206},[200,945,946],{"class":344}," 'ArrowUp'",[200,948,348],{"class":221},[200,950,951,953,955],{"class":202,"line":426},[200,952,511],{"class":221},[200,954,514],{"class":217},[200,956,517],{"class":221},[200,958,959,961,963,965,968,970,972,974,977,979],{"class":202,"line":431},[200,960,909],{"class":221},[200,962,357],{"class":206},[200,964,914],{"class":221},[200,966,967],{"class":217},"max",[200,969,920],{"class":221},[200,971,405],{"class":206},[200,973,366],{"class":210},[200,975,976],{"class":221},", ",[200,978,225],{"class":210},[200,980,830],{"class":221},[200,982,983],{"class":202,"line":441},[200,984,383],{"class":206},[200,986,987,989,991],{"class":202,"line":451},[200,988,341],{"class":206},[200,990,493],{"class":344},[200,992,348],{"class":221},[200,994,995,997,999],{"class":202,"line":456},[200,996,511],{"class":221},[200,998,514],{"class":217},[200,1000,517],{"class":221},[200,1002,1003,1006],{"class":202,"line":466},[200,1004,1005],{"class":217},"      selectOption",[200,1007,1008],{"class":221},"(options[activeIndex.value])\n",[200,1010,1011],{"class":202,"line":483},[200,1012,383],{"class":206},[200,1014,1015],{"class":202,"line":488},[200,1016,540],{"class":221},[200,1018,1019],{"class":202,"line":498},[200,1020,604],{"class":221},[191,1022,1024],{"className":607,"code":1023,"language":609,"meta":196,"style":196},"\u003Cinput\n  role=\"combobox\"\n  :aria-activedescendant=\"activeDescendant\"\n  :aria-controls=\"listboxId\"\n  aria-expanded=\"true\"\n  @keydown=\"handleKeydown\"\n\u002F>\n\u003Cul :id=\"listboxId\" role=\"listbox\">\n  \u003Cli\n    v-for=\"option in options\"\n    :id=\"option.id\"\n    role=\"option\"\n    :aria-selected=\"option.id === activeDescendant\"\n  >\n    {{ option.label }}\n  \u003C\u002Fli>\n\u003C\u002Ful>\n",[123,1025,1026,1033,1042,1057,1073,1083,1097,1102,1107,1112,1117,1122,1127,1132,1137,1142,1147],{"__ignoreMap":196},[200,1027,1028,1030],{"class":202,"line":203},[200,1029,616],{"class":221},[200,1031,1032],{"class":619},"input\n",[200,1034,1035,1037,1039],{"class":202,"line":12},[200,1036,647],{"class":217},[200,1038,357],{"class":221},[200,1040,1041],{"class":344},"\"combobox\"\n",[200,1043,1044,1046,1048,1050,1052,1055],{"class":202,"line":19},[200,1045,657],{"class":221},[200,1047,66],{"class":217},[200,1049,357],{"class":221},[200,1051,630],{"class":344},[200,1053,1054],{"class":221},"activeDescendant",[200,1056,642],{"class":344},[200,1058,1059,1061,1064,1066,1068,1071],{"class":202,"line":254},[200,1060,657],{"class":221},[200,1062,1063],{"class":217},"aria-controls",[200,1065,357],{"class":221},[200,1067,630],{"class":344},[200,1069,1070],{"class":221},"listboxId",[200,1072,642],{"class":344},[200,1074,1075,1078,1080],{"class":202,"line":279},[200,1076,1077],{"class":217},"  aria-expanded",[200,1079,357],{"class":221},[200,1081,1082],{"class":344},"\"true\"\n",[200,1084,1085,1087,1089,1091,1093,1095],{"class":202,"line":299},[200,1086,712],{"class":221},[200,1088,715],{"class":217},[200,1090,357],{"class":221},[200,1092,630],{"class":344},[200,1094,722],{"class":221},[200,1096,642],{"class":344},[200,1098,1099],{"class":202,"line":324},[200,1100,1101],{"class":221},"\u002F>\n",[200,1103,1104],{"class":202,"line":329},[200,1105,1106],{"class":221},"\u003Cul :id=\"listboxId\" role=\"listbox\">\n",[200,1108,1109],{"class":202,"line":338},[200,1110,1111],{"class":221},"  \u003Cli\n",[200,1113,1114],{"class":202,"line":351},[200,1115,1116],{"class":221},"    v-for=\"option in options\"\n",[200,1118,1119],{"class":202,"line":380},[200,1120,1121],{"class":221},"    :id=\"option.id\"\n",[200,1123,1124],{"class":202,"line":386},[200,1125,1126],{"class":221},"    role=\"option\"\n",[200,1128,1129],{"class":202,"line":396},[200,1130,1131],{"class":221},"    :aria-selected=\"option.id === activeDescendant\"\n",[200,1133,1134],{"class":202,"line":426},[200,1135,1136],{"class":221},"  >\n",[200,1138,1139],{"class":202,"line":431},[200,1140,1141],{"class":221},"    {{ option.label }}\n",[200,1143,1144],{"class":202,"line":441},[200,1145,1146],{"class":221},"  \u003C\u002Fli>\n",[200,1148,1149],{"class":202,"line":451},[200,1150,1151],{"class":221},"\u003C\u002Ful>\n",[56,1153,1154,1155,1157,1158,1160,1161,1164,1165,1167],{},"Every option needs a unique ",[123,1156,762],{}," that matches what ",[123,1159,66],{}," references. When filtering changes the list, reset ",[123,1162,1163],{},"activeIndex"," — a stale reference to a removed ",[123,1166,762],{}," breaks the announcement chain.",[72,1169,32],{"id":31},[1171,1172,1173,1188],"table",{},[1174,1175,1176],"thead",{},[1177,1178,1179,1183,1186],"tr",{},[1180,1181,1182],"th",{},"Criteria",[1180,1184,1185],{},"Roving Tabindex",[1180,1187,66],{},[1189,1190,1191,1203,1214,1225,1236,1247,1258],"tbody",{},[1177,1192,1193,1197,1200],{},[1194,1195,1196],"td",{},"DOM focus",[1194,1198,1199],{},"Moves to each item",[1194,1201,1202],{},"Stays on container",[1177,1204,1205,1208,1211],{},[1194,1206,1207],{},"Best for",[1194,1209,1210],{},"Tabs, toolbars, radio groups, menu bars",[1194,1212,1213],{},"Comboboxes, searchable selects, listboxes with input",[1177,1215,1216,1219,1222],{},[1194,1217,1218],{},"Input field",[1194,1220,1221],{},"Not needed",[1194,1223,1224],{},"Required (or container acts as one)",[1177,1226,1227,1230,1233],{},[1194,1228,1229],{},"Browser support",[1194,1231,1232],{},"Universal",[1194,1234,1235],{},"Universal (but SR support varies)",[1177,1237,1238,1241,1244],{},[1194,1239,1240],{},"Complexity",[1194,1242,1243],{},"Lower",[1194,1245,1246],{},"Higher (need unique ids, careful attribute management)",[1177,1248,1249,1252,1255],{},[1194,1250,1251],{},"Filtering\u002Fsearch",[1194,1253,1254],{},"Awkward (focus + input conflict)",[1194,1256,1257],{},"Natural (focus stays on input)",[1177,1259,1260,1263,1266],{},[1194,1261,1262],{},"Screen reader",[1194,1264,1265],{},"Directly announced (element is focused)",[1194,1267,1268],{},"Announced via relationship (more indirection)",[145,1270,35],{"id":34},[56,1272,1273],{},"Three cases cover almost every widget:",[148,1275,1276,1285,1294],{},[84,1277,1278,1281,1282,1284],{},[60,1279,1280],{},"The widget has an input field"," that the user types into while navigating options → ",[60,1283,66],{},". This is the only pattern that lets the input stay focused while options are highlighted. Comboboxes, searchable selects, autocompletes — all use this.",[84,1286,1287,1290,1291,1293],{},[60,1288,1289],{},"Items are standalone interactive elements"," like tabs, toolbar buttons, or menu items → ",[60,1292,62],{},". Each item is a real focusable element, and there's no input to maintain focus on.",[84,1295,1296,1299,1300,1302],{},[60,1297,1298],{},"Not sure"," → ",[60,1301,62],{},". It's simpler to implement, has more consistent screen reader support, and covers most composite widget patterns.",[72,1304,38],{"id":37},[148,1306,1307,1319,1325,1353,1370],{},[84,1308,1309,1312,1313,1318],{},[60,1310,1311],{},"Forgetting"," ",[60,1314,1315],{},[123,1316,1317],{},"event.preventDefault()"," — Arrow keys scroll the page if not prevented. Every arrow key handler in a composite widget needs this.",[84,1320,1321,1324],{},[60,1322,1323],{},"Not wrapping at boundaries"," — When arrowing past the last item, should it cycle to the first or stop? Either is fine, but be consistent. The W3C Tabs pattern recommends wrapping; listboxes typically don't.",[84,1326,1327,1312,1330,1312,1335,1338,1339,976,1342,976,1345,1348,1349,1352],{},[60,1328,1329],{},"Missing",[60,1331,1332],{},[123,1333,1334],{},"role",[60,1336,1337],{},"attributes"," — Without ",[123,1340,1341],{},"role=\"tablist\"",[123,1343,1344],{},"role=\"tab\"",[123,1346,1347],{},"role=\"listbox\"",", and ",[123,1350,1351],{},"role=\"option\"",", screen readers can't identify the widget pattern and won't announce items correctly.",[84,1354,1355,1312,1358,1362,1363,1365,1366,1369],{},[60,1356,1357],{},"Stale",[60,1359,1360],{},[123,1361,66],{}," — When filtering changes the option list, the id referenced by ",[123,1364,66],{}," might no longer exist in the DOM. Always reset ",[123,1367,1368],{},"highlightedIndex"," (or recompute it) when the filtered list changes.",[84,1371,1372,1375,1376,1379],{},[60,1373,1374],{},"Not scrolling highlighted items into view"," — In long lists, arrowing through options can move the highlight off-screen. Call ",[123,1377,1378],{},"scrollIntoView({ block: 'nearest' })"," on the highlighted element after each navigation.",[72,1381,41],{"id":40},[81,1383,1384,1393,1400,1407],{},[84,1385,1386],{},[1387,1388,1392],"a",{"href":1389,"rel":1390},"https:\u002F\u002Fwww.w3.org\u002FWAI\u002FARIA\u002Fapg\u002Fpractices\u002Fkeyboard-interface\u002F",[1391],"nofollow","W3C APG: Developing a Keyboard Interface",[84,1394,1395],{},[1387,1396,1399],{"href":1397,"rel":1398},"https:\u002F\u002Fwww.w3.org\u002FWAI\u002FARIA\u002Fapg\u002Fpatterns\u002Ftabs\u002F",[1391],"W3C APG: Tabs Pattern",[84,1401,1402],{},[1387,1403,1406],{"href":1404,"rel":1405},"https:\u002F\u002Fwww.w3.org\u002FWAI\u002FARIA\u002Fapg\u002Fpatterns\u002Fcombobox\u002F",[1391],"W3C APG: Combobox Pattern",[84,1408,1409],{},[1387,1410,1413],{"href":1411,"rel":1412},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAccessibility\u002FARIA\u002FAttributes\u002Faria-activedescendant",[1391],"MDN: aria-activedescendant",[72,1415,44],{"id":43},[81,1417,1418],{},[84,1419,1420,1424],{},[1387,1421,1423],{"href":1422},"\u002Fblog\u002Fuse-announcer-nuxt","Announcing Dynamic Changes in Vue with aria-live"," — for changes that have no focus target at all",[1426,1427,1428],"style",{},"html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .s-3mD, html code.shiki .s-3mD{--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sxg3X, html code.shiki .sxg3X{--shiki-default:#85E89D;--shiki-dark:#85E89D}",{"title":196,"searchDepth":12,"depth":12,"links":1430},[1431,1432,1436,1440,1443,1444,1445],{"id":10,"depth":12,"text":11},{"id":14,"depth":12,"text":15,"children":1433},[1434,1435],{"id":17,"depth":19,"text":18},{"id":21,"depth":19,"text":22},{"id":24,"depth":12,"text":25,"children":1437},[1438,1439],{"id":27,"depth":19,"text":18},{"id":29,"depth":19,"text":22},{"id":31,"depth":12,"text":32,"children":1441},[1442],{"id":34,"depth":19,"text":35},{"id":37,"depth":12,"text":38},{"id":40,"depth":12,"text":41},{"id":43,"depth":12,"text":44},"Roving tabindex vs aria-activedescendant — when to use each pattern, with interactive demos and implementation examples.","md",false,{},null,"\u002Fblog\u002Fkeyboard-navigation-composite-widgets",{"title":47,"description":1446},[1454,1455,1456],"VUE","A11Y","ARIA","blog\u002Fkeyboard-navigation-composite-widgets","ACCESSIBILITY","HgwmKmDyR8S8KUB-CVhXxtGM7kFK-dTv8m-9BokwFsc",1780311892629]