[{"data":1,"prerenderedAt":4000},["ShallowReactive",2],{"layout-sidebar-\u002F":3,"all-home-projects":5,"all-home-posts":150,"home-contributions":3964},{"type":4},"about",[6,30,48,64,76,92,106,120,134],{"id":7,"title":8,"description":9,"extension":10,"featured":11,"hidden":11,"label":12,"meta":13,"order":14,"specs":15,"stack":20,"stars":25,"stem":26,"tag":27,"updatedAt":25,"url":28,"__hash__":29},"projects\u002Fprojects\u002Fanny-co.yml","anny.co","Booking and resource management platform — full-stack development.","yml",false,"ANNY.CO",{},10,[16,17,18,19],"VUE","NUXT","LARAVEL","TYPESCRIPT",[21,22,23,24],"Vue","Nuxt","Laravel","TypeScript",null,"projects\u002Fanny-co","PROFESSIONAL","https:\u002F\u002Fanny.co","oLsz8olCViiZ3PiudP9Ir5smBOltHFEaRrsp5xDRof4",{"id":31,"title":32,"description":33,"extension":10,"featured":11,"hidden":11,"label":34,"meta":35,"order":36,"specs":37,"stack":40,"stars":42,"stem":43,"tag":44,"updatedAt":45,"url":46,"__hash__":47},"projects\u002Fprojects\u002Farc-boosts.yml","Arc Boosts","Collection of Arc browser boosts for Spotify, Google Calendar, and YouTube.","ARC-BOOSTS",{},6,[38,39],"CSS","JAVASCRIPT",[38,41],"JavaScript",12,"projects\u002Farc-boosts","BROWSER","2025-06-14T14:12:58Z","https:\u002F\u002Fgithub.com\u002Ffabkho\u002Farc_boosts","AZmYo1xQPA0tmDJrVpxX7dBzCDr8nO3EratVZWP72PU",{"id":49,"title":50,"description":51,"extension":10,"featured":11,"hidden":11,"label":52,"meta":53,"order":54,"specs":55,"stack":57,"stars":58,"stem":59,"tag":60,"updatedAt":61,"url":62,"__hash__":63},"projects\u002Fprojects\u002Fcmd-bar.yml","Cmd Bar","Performant, unstyled command palette for Vue 3.","CMD-BAR",{},4,[16,19,56],"A11Y",[21,24],7,"projects\u002Fcmd-bar","VUE COMPONENT","2026-03-23T11:53:46Z","https:\u002F\u002Fgithub.com\u002Ffabkho\u002Fcmd-bar","AvhW1qwv6qMUS0_o3OyoUTzM6WVKPvq1IfcM7iZKXco",{"id":65,"title":66,"description":67,"extension":10,"featured":11,"hidden":11,"label":68,"meta":69,"order":25,"specs":70,"stack":25,"stars":71,"stem":72,"tag":19,"updatedAt":73,"url":74,"__hash__":75},"projects\u002Fprojects\u002Fconductor.yml","Conductor","Pi extension for multi-repo bugfixing — ClickUp to MR in one command","CONDUCTOR",{},[19],0,"projects\u002Fconductor","2026-05-30T19:25:25Z","https:\u002F\u002Fgithub.com\u002Ffabkho\u002Fconductor","RnO3ESXuHy649y2Q6c_XSqvSNGmqN4XSuFByHE8N9lU",{"id":77,"title":78,"description":79,"extension":10,"featured":11,"hidden":11,"label":80,"meta":81,"order":82,"specs":83,"stack":86,"stars":71,"stem":87,"tag":88,"updatedAt":89,"url":90,"__hash__":91},"projects\u002Fprojects\u002Ffontawesome-esm.yml","Fontawesome ESM","Convert FontAwesome CJS icons to ES Modules for tree-shaking in Vue\u002FNuxt.","FONTAWESOME-ESM",{},5,[16,84,85],"BUNDLER","PERFORMANCE",[21,41],"projects\u002Ffontawesome-esm","BUILD TOOL","2026-02-04T01:42:00Z","https:\u002F\u002Fgithub.com\u002Ffabkho\u002Ffontawesome-esm","zxEepwGkD26wAtbXXeQhsd5_rgJGlNxZU9wmbnbSmdY",{"id":93,"title":94,"description":95,"extension":10,"featured":11,"hidden":11,"label":96,"meta":97,"order":58,"specs":98,"stack":100,"stars":71,"stem":101,"tag":102,"updatedAt":103,"url":104,"__hash__":105},"projects\u002Fprojects\u002Fi-love-pi.yml","I Love Pi","Experimental Pi coding agent extensions and workflows.","I-LOVE-PI",{},[19,99],"AI",[24],"projects\u002Fi-love-pi","EXPERIMENT","2026-05-03T14:18:24Z","https:\u002F\u002Fgithub.com\u002Ffabkho\u002Fi-love-pi","XZYBJihHy45coWIf1LbvDEMF3R7wO92Mq9zOCz7Nu9w",{"id":107,"title":108,"description":109,"extension":10,"featured":11,"hidden":11,"label":110,"meta":111,"order":25,"specs":112,"stack":25,"stars":71,"stem":116,"tag":19,"updatedAt":117,"url":118,"__hash__":119},"projects\u002Fprojects\u002Fpi-solo.yml","Pi Solo","Pi extensions for Soloterm — agent orchestration, dispatch, and fork workflows","PI SOLO",{},[19,113,114,115],"EXTENSION","PI","SOLO","projects\u002Fpi-solo","2026-05-20T11:35:04Z","https:\u002F\u002Fgithub.com\u002Ffabkho\u002Fpi-solo","45Uim08PnAiMTIdF35UF_Df12FKmPpNiZwgpXhn2WaI",{"id":121,"title":122,"description":123,"extension":10,"featured":11,"hidden":11,"label":124,"meta":125,"order":126,"specs":127,"stack":129,"stars":25,"stem":130,"tag":131,"updatedAt":25,"url":132,"__hash__":133},"projects\u002Fprojects\u002Freading-tracker-cli.yml","Reading Tracker CLI","Track your reading progress from the terminal.","READING-TRACKER",{},3,[19,128],"CLI",[24],"projects\u002Freading-tracker-cli","CLI TOOL","https:\u002F\u002Fgithub.com\u002Ffabkho\u002Freading-tracker-cli","zWGewb3LVX_lb_mjdXDL1f-dgO8KjO_vVQpRvIIWtzM",{"id":135,"title":136,"description":137,"extension":10,"featured":138,"hidden":11,"label":139,"meta":140,"order":141,"specs":142,"stack":143,"stars":144,"stem":145,"tag":146,"updatedAt":147,"url":148,"__hash__":149},"projects\u002Fprojects\u002Fthe-i18n-kit.yml","The I18n Kit","A complete i18n toolkit for Nuxt — extraction, validation, and AI-powered translation.",true,"I18N-KIT",{},1,[17,19,99],[22,24],9,"projects\u002Fthe-i18n-kit","NUXT MODULE","2026-05-16T11:30:37Z","https:\u002F\u002Fgithub.com\u002Ffabkho\u002Fthe-i18n-kit","iecSewqkO33JgF-fSf1O4utAFAoKza9zDUUessBLD8o",[151,1579,3232],{"id":152,"title":153,"author":154,"body":155,"date":1567,"description":1568,"extension":1569,"featured":11,"meta":1570,"navigation":138,"order":25,"path":1571,"seo":1572,"specs":1573,"status":1575,"stem":1576,"tag":1577,"__hash__":1578},"blog\u002Fblog\u002Fkeyboard-navigation-composite-widgets.md","Keyboard Navigation in Composite Widgets","Fabian Kirchhoff",{"type":156,"value":157,"toc":1550},"minimark",[158,162,175,178,183,186,189,223,226,230,249,252,255,260,289,295,299,305,712,852,855,859,873,876,879,882,910,913,916,919,1132,1263,1279,1283,1382,1386,1389,1418,1422,1497,1501,1533,1537,1546],[159,160,153],"h1",{"id":161},"keyboard-navigation-in-composite-widgets",[163,164,165,166,170,171,174],"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: ",[167,168,169],"strong",{},"roving tabindex"," and ",[167,172,173],{},"aria-activedescendant",". They solve the same problem differently, and picking the wrong one makes the widget harder to use.",[163,176,177],{},"This post walks through both patterns with interactive demos and production implementation details.",[179,180,182],"h2",{"id":181},"the-problem-tab-key-overload","The Problem: Tab Key Overload",[163,184,185],{},"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.",[163,187,188],{},"Composite widgets should work like this:",[190,191,192,207,217],"ul",{},[193,194,195,198,199,170,203,206],"li",{},[167,196,197],{},"Tab"," moves focus ",[200,201,202],"em",{},"into",[200,204,205],{},"out of"," the widget (one tab stop)",[193,208,209,212,213,216],{},[167,210,211],{},"Arrow keys"," navigate between items ",[200,214,215],{},"within"," the widget",[193,218,219,222],{},[167,220,221],{},"Enter\u002FSpace"," activates the focused item",[163,224,225],{},"This is the pattern every native OS widget follows. Both roving tabindex and aria-activedescendant achieve it — but through different mechanisms.",[179,227,229],{"id":228},"pattern-1-roving-tabindex","Pattern 1: Roving Tabindex",[163,231,232,233,237,238,241,242,244,245,248],{},"Only one item in the group has ",[234,235,236],"code",{},"tabindex=\"0\""," at any given time. All other items have ",[234,239,240],{},"tabindex=\"-1\"",". When arrow keys are pressed, ",[234,243,236],{}," moves to the new target and ",[234,246,247],{},".focus()"," is called on it. Focus actually moves in the DOM.",[250,251],"roving-tab-demo",{},[163,253,254],{},"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.",[256,257,259],"h3",{"id":258},"how-it-works","How It Works",[261,262,263,271,280,286],"ol",{},[193,264,265,266,268,269],{},"One element gets ",[234,267,236],{},", the rest get ",[234,270,240],{},[193,272,273,274,276,277,279],{},"Arrow keys update which element has ",[234,275,236],{}," and call ",[234,278,247],{}," on it",[193,281,282,283,285],{},"Tab leaves the widget entirely. Shift+Tab re-enters at the last focused item (because it still has ",[234,284,236],{},")",[193,287,288],{},"Home\u002FEnd jump to first\u002Flast item",[163,290,291,292,294],{},"When the user tabs away and later tabs back, the previously focused item still has ",[234,293,236],{}," — focus returns right where they left off.",[256,296,298],{"id":297},"implementation","Implementation",[163,300,301,302,304],{},"The core logic tracks which tab holds ",[234,303,236],{}," (keyboard focus) separately from which tab is active (displayed panel):",[306,307,312],"pre",{"className":308,"code":309,"language":310,"meta":311,"style":311},"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","",[234,313,314,343,361,366,390,409,433,437,446,458,486,492,501,531,536,546,556,561,571,588,593,603,613,625,636,642,648,653,669,679,690,701,706],{"__ignoreMap":311},[315,316,318,322,326,329,333,337,340],"span",{"class":317,"line":141},"line",[315,319,321],{"class":320},"sOPea","const",[315,323,325],{"class":324},"s8ozJ"," focusedTab",[315,327,328],{"class":320}," =",[315,330,332],{"class":331},"sFR8T"," ref",[315,334,336],{"class":335},"suv1-","(tabs[",[315,338,339],{"class":324},"0",[315,341,342],{"class":335},"])\n",[315,344,346,348,351,353,355,357,359],{"class":317,"line":345},2,[315,347,321],{"class":320},[315,349,350],{"class":324}," activeTab",[315,352,328],{"class":320},[315,354,332],{"class":331},[315,356,336],{"class":335},[315,358,339],{"class":324},[315,360,342],{"class":335},[315,362,363],{"class":317,"line":126},[315,364,365],{"emptyLinePlaceholder":138},"\n",[315,367,368,371,374,377,381,384,387],{"class":317,"line":54},[315,369,370],{"class":320},"function",[315,372,373],{"class":331}," handleKeydown",[315,375,376],{"class":335},"(",[315,378,380],{"class":379},"s-3mD","event",[315,382,383],{"class":320},":",[315,385,386],{"class":331}," KeyboardEvent",[315,388,389],{"class":335},") {\n",[315,391,392,395,398,400,403,406],{"class":317,"line":82},[315,393,394],{"class":320},"  const",[315,396,397],{"class":324}," idx",[315,399,328],{"class":320},[315,401,402],{"class":335}," tabs.",[315,404,405],{"class":331},"indexOf",[315,407,408],{"class":335},"(focusedTab.value)\n",[315,410,411,414,417,419,422,425,428,430],{"class":317,"line":36},[315,412,413],{"class":320},"  let",[315,415,416],{"class":335}," next",[315,418,383],{"class":320},[315,420,421],{"class":324}," number",[315,423,424],{"class":320}," |",[315,426,427],{"class":324}," null",[315,429,328],{"class":320},[315,431,432],{"class":324}," null\n",[315,434,435],{"class":317,"line":58},[315,436,365],{"emptyLinePlaceholder":138},[315,438,440,443],{"class":317,"line":439},8,[315,441,442],{"class":320},"  switch",[315,444,445],{"class":335}," (event.key) {\n",[315,447,448,451,455],{"class":317,"line":144},[315,449,450],{"class":320},"    case",[315,452,454],{"class":453},"s4wv1"," 'ArrowRight'",[315,456,457],{"class":335},":\n",[315,459,460,463,466,469,472,475,478,481,483],{"class":317,"line":14},[315,461,462],{"class":335},"      next ",[315,464,465],{"class":320},"=",[315,467,468],{"class":335}," (idx ",[315,470,471],{"class":320},"+",[315,473,474],{"class":324}," 1",[315,476,477],{"class":335},") ",[315,479,480],{"class":320},"%",[315,482,402],{"class":335},[315,484,485],{"class":324},"length\n",[315,487,489],{"class":317,"line":488},11,[315,490,491],{"class":320},"      break\n",[315,493,494,496,499],{"class":317,"line":42},[315,495,450],{"class":320},[315,497,498],{"class":453}," 'ArrowLeft'",[315,500,457],{"class":335},[315,502,504,506,508,510,513,515,518,520,523,525,527,529],{"class":317,"line":503},13,[315,505,462],{"class":335},[315,507,465],{"class":320},[315,509,468],{"class":335},[315,511,512],{"class":320},"-",[315,514,474],{"class":324},[315,516,517],{"class":320}," +",[315,519,402],{"class":335},[315,521,522],{"class":324},"length",[315,524,477],{"class":335},[315,526,480],{"class":320},[315,528,402],{"class":335},[315,530,485],{"class":324},[315,532,534],{"class":317,"line":533},14,[315,535,491],{"class":320},[315,537,539,541,544],{"class":317,"line":538},15,[315,540,450],{"class":320},[315,542,543],{"class":453}," 'Home'",[315,545,457],{"class":335},[315,547,549,551,553],{"class":317,"line":548},16,[315,550,462],{"class":335},[315,552,465],{"class":320},[315,554,555],{"class":324}," 0\n",[315,557,559],{"class":317,"line":558},17,[315,560,491],{"class":320},[315,562,564,566,569],{"class":317,"line":563},18,[315,565,450],{"class":320},[315,567,568],{"class":453}," 'End'",[315,570,457],{"class":335},[315,572,574,576,578,580,582,585],{"class":317,"line":573},19,[315,575,462],{"class":335},[315,577,465],{"class":320},[315,579,402],{"class":335},[315,581,522],{"class":324},[315,583,584],{"class":320}," -",[315,586,587],{"class":324}," 1\n",[315,589,591],{"class":317,"line":590},20,[315,592,491],{"class":320},[315,594,596,598,601],{"class":317,"line":595},21,[315,597,450],{"class":320},[315,599,600],{"class":453}," 'Enter'",[315,602,457],{"class":335},[315,604,606,608,611],{"class":317,"line":605},22,[315,607,450],{"class":320},[315,609,610],{"class":453}," ' '",[315,612,457],{"class":335},[315,614,616,619,622],{"class":317,"line":615},23,[315,617,618],{"class":335},"      event.",[315,620,621],{"class":331},"preventDefault",[315,623,624],{"class":335},"()\n",[315,626,628,631,633],{"class":317,"line":627},24,[315,629,630],{"class":335},"      activeTab.value ",[315,632,465],{"class":320},[315,634,635],{"class":335}," focusedTab.value\n",[315,637,639],{"class":317,"line":638},25,[315,640,641],{"class":320},"      return\n",[315,643,645],{"class":317,"line":644},26,[315,646,647],{"class":335},"  }\n",[315,649,651],{"class":317,"line":650},27,[315,652,365],{"emptyLinePlaceholder":138},[315,654,656,659,662,665,667],{"class":317,"line":655},28,[315,657,658],{"class":320},"  if",[315,660,661],{"class":335}," (next ",[315,663,664],{"class":320},"!==",[315,666,427],{"class":324},[315,668,389],{"class":335},[315,670,672,675,677],{"class":317,"line":671},29,[315,673,674],{"class":335},"    event.",[315,676,621],{"class":331},[315,678,624],{"class":335},[315,680,682,685,687],{"class":317,"line":681},30,[315,683,684],{"class":335},"    focusedTab.value ",[315,686,465],{"class":320},[315,688,689],{"class":335}," tabs[next]\n",[315,691,693,696,699],{"class":317,"line":692},31,[315,694,695],{"class":335},"    tabRefs[next].",[315,697,698],{"class":331},"focus",[315,700,624],{"class":335},[315,702,704],{"class":317,"line":703},32,[315,705,647],{"class":335},[315,707,709],{"class":317,"line":708},33,[315,710,711],{"class":335},"}\n",[306,713,717],{"className":714,"code":715,"language":716,"meta":311,"style":311},"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",[234,718,719,728,750,760,781,815,832,837,842],{"__ignoreMap":311},[315,720,721,724],{"class":317,"line":141},[315,722,723],{"class":335},"\u003C",[315,725,727],{"class":726},"sxg3X","button\n",[315,729,730,733,735,738,741,744,747],{"class":317,"line":345},[315,731,732],{"class":320},"  v-for",[315,734,465],{"class":335},[315,736,737],{"class":453},"\"",[315,739,740],{"class":335},"tab ",[315,742,743],{"class":320},"in",[315,745,746],{"class":335}," tabs",[315,748,749],{"class":453},"\"\n",[315,751,752,755,757],{"class":317,"line":126},[315,753,754],{"class":331},"  role",[315,756,465],{"class":335},[315,758,759],{"class":453},"\"tab\"\n",[315,761,762,765,768,770,772,774,777,779],{"class":317,"line":54},[315,763,764],{"class":335},"  :",[315,766,767],{"class":331},"aria-selected",[315,769,465],{"class":335},[315,771,737],{"class":453},[315,773,740],{"class":335},[315,775,776],{"class":320},"===",[315,778,350],{"class":335},[315,780,749],{"class":453},[315,782,783,785,788,790,792,794,796,799,802,805,808,810,813],{"class":317,"line":82},[315,784,764],{"class":335},[315,786,787],{"class":331},"tabindex",[315,789,465],{"class":335},[315,791,737],{"class":453},[315,793,740],{"class":335},[315,795,776],{"class":320},[315,797,798],{"class":335}," focusedTab ",[315,800,801],{"class":320},"?",[315,803,804],{"class":324}," 0",[315,806,807],{"class":320}," :",[315,809,584],{"class":320},[315,811,812],{"class":324},"1",[315,814,749],{"class":453},[315,816,817,820,823,825,827,830],{"class":317,"line":36},[315,818,819],{"class":335},"  @",[315,821,822],{"class":331},"keydown",[315,824,465],{"class":335},[315,826,737],{"class":453},[315,828,829],{"class":335},"handleKeydown",[315,831,749],{"class":453},[315,833,834],{"class":317,"line":58},[315,835,836],{"class":335},">\n",[315,838,839],{"class":317,"line":439},[315,840,841],{"class":335},"  {{ tab.label }}\n",[315,843,844,847,850],{"class":317,"line":144},[315,845,846],{"class":335},"\u003C\u002F",[315,848,849],{"class":726},"button",[315,851,836],{"class":335},[163,853,854],{},"Circular wrapping (ArrowRight on last goes to first) matches the W3C Tabs pattern recommendation.",[179,856,858],{"id":857},"pattern-2-aria-activedescendant","Pattern 2: aria-activedescendant",[163,860,861,862,865,866,868,869,872],{},"Focus stays on a container element — usually an ",[234,863,864],{},"\u003Cinput>",". The container's ",[234,867,173],{}," attribute points to the ",[234,870,871],{},"id"," of the currently \"active\" option. The screen reader announces the referenced element even though it never receives DOM focus.",[874,875],"aria-active-descendant-demo",{},[163,877,878],{},"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.",[256,880,259],{"id":881},"how-it-works-1",[261,883,884,891,899,902,907],{},[193,885,886,887,890],{},"The container (input or div with ",[234,888,889],{},"role=\"combobox\"",") keeps DOM focus at all times",[193,892,893,894,896,897],{},"Arrow keys update ",[234,895,173],{}," to reference a different option's ",[234,898,871],{},[193,900,901],{},"The referenced option is visually highlighted and announced by screen readers",[193,903,904,905],{},"Option elements never receive ",[234,906,247],{},[193,908,909],{},"Because focus stays on the input, it remains editable while navigating options",[163,911,912],{},"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.",[256,914,298],{"id":915},"implementation-1",[163,917,918],{},"The input keeps focus and manages everything through attribute updates:",[306,920,922],{"className":308,"code":921,"language":310,"meta":311,"style":311},"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",[234,923,924,942,946,964,969,973,977,993,999,1008,1016,1047,1051,1060,1068,1092,1096,1104,1112,1120,1124,1128],{"__ignoreMap":311},[315,925,926,928,931,933,935,937,939],{"class":317,"line":141},[315,927,321],{"class":320},[315,929,930],{"class":324}," activeIndex",[315,932,328],{"class":320},[315,934,332],{"class":331},[315,936,376],{"class":335},[315,938,339],{"class":324},[315,940,941],{"class":335},")\n",[315,943,944],{"class":317,"line":345},[315,945,365],{"emptyLinePlaceholder":138},[315,947,948,950,953,955,958,961],{"class":317,"line":126},[315,949,321],{"class":320},[315,951,952],{"class":324}," activeDescendant",[315,954,328],{"class":320},[315,956,957],{"class":331}," computed",[315,959,960],{"class":335},"(() ",[315,962,963],{"class":320},"=>\n",[315,965,966],{"class":317,"line":54},[315,967,968],{"class":335},"  options[activeIndex.value]?.id\n",[315,970,971],{"class":317,"line":82},[315,972,941],{"class":335},[315,974,975],{"class":317,"line":36},[315,976,365],{"emptyLinePlaceholder":138},[315,978,979,981,983,985,987,989,991],{"class":317,"line":58},[315,980,370],{"class":320},[315,982,373],{"class":331},[315,984,376],{"class":335},[315,986,380],{"class":379},[315,988,383],{"class":320},[315,990,386],{"class":331},[315,992,389],{"class":335},[315,994,995,997],{"class":317,"line":439},[315,996,442],{"class":320},[315,998,445],{"class":335},[315,1000,1001,1003,1006],{"class":317,"line":144},[315,1002,450],{"class":320},[315,1004,1005],{"class":453}," 'ArrowDown'",[315,1007,457],{"class":335},[315,1009,1010,1012,1014],{"class":317,"line":14},[315,1011,618],{"class":335},[315,1013,621],{"class":331},[315,1015,624],{"class":335},[315,1017,1018,1021,1023,1026,1029,1032,1034,1036,1039,1041,1043,1045],{"class":317,"line":488},[315,1019,1020],{"class":335},"      activeIndex.value ",[315,1022,465],{"class":320},[315,1024,1025],{"class":335}," Math.",[315,1027,1028],{"class":331},"min",[315,1030,1031],{"class":335},"(activeIndex.value ",[315,1033,471],{"class":320},[315,1035,474],{"class":324},[315,1037,1038],{"class":335},", options.",[315,1040,522],{"class":324},[315,1042,584],{"class":320},[315,1044,474],{"class":324},[315,1046,941],{"class":335},[315,1048,1049],{"class":317,"line":42},[315,1050,491],{"class":320},[315,1052,1053,1055,1058],{"class":317,"line":503},[315,1054,450],{"class":320},[315,1056,1057],{"class":453}," 'ArrowUp'",[315,1059,457],{"class":335},[315,1061,1062,1064,1066],{"class":317,"line":533},[315,1063,618],{"class":335},[315,1065,621],{"class":331},[315,1067,624],{"class":335},[315,1069,1070,1072,1074,1076,1079,1081,1083,1085,1088,1090],{"class":317,"line":538},[315,1071,1020],{"class":335},[315,1073,465],{"class":320},[315,1075,1025],{"class":335},[315,1077,1078],{"class":331},"max",[315,1080,1031],{"class":335},[315,1082,512],{"class":320},[315,1084,474],{"class":324},[315,1086,1087],{"class":335},", ",[315,1089,339],{"class":324},[315,1091,941],{"class":335},[315,1093,1094],{"class":317,"line":548},[315,1095,491],{"class":320},[315,1097,1098,1100,1102],{"class":317,"line":558},[315,1099,450],{"class":320},[315,1101,600],{"class":453},[315,1103,457],{"class":335},[315,1105,1106,1108,1110],{"class":317,"line":563},[315,1107,618],{"class":335},[315,1109,621],{"class":331},[315,1111,624],{"class":335},[315,1113,1114,1117],{"class":317,"line":573},[315,1115,1116],{"class":331},"      selectOption",[315,1118,1119],{"class":335},"(options[activeIndex.value])\n",[315,1121,1122],{"class":317,"line":590},[315,1123,491],{"class":320},[315,1125,1126],{"class":317,"line":595},[315,1127,647],{"class":335},[315,1129,1130],{"class":317,"line":605},[315,1131,711],{"class":335},[306,1133,1135],{"className":714,"code":1134,"language":716,"meta":311,"style":311},"\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",[234,1136,1137,1144,1153,1168,1184,1194,1208,1213,1218,1223,1228,1233,1238,1243,1248,1253,1258],{"__ignoreMap":311},[315,1138,1139,1141],{"class":317,"line":141},[315,1140,723],{"class":335},[315,1142,1143],{"class":726},"input\n",[315,1145,1146,1148,1150],{"class":317,"line":345},[315,1147,754],{"class":331},[315,1149,465],{"class":335},[315,1151,1152],{"class":453},"\"combobox\"\n",[315,1154,1155,1157,1159,1161,1163,1166],{"class":317,"line":126},[315,1156,764],{"class":335},[315,1158,173],{"class":331},[315,1160,465],{"class":335},[315,1162,737],{"class":453},[315,1164,1165],{"class":335},"activeDescendant",[315,1167,749],{"class":453},[315,1169,1170,1172,1175,1177,1179,1182],{"class":317,"line":54},[315,1171,764],{"class":335},[315,1173,1174],{"class":331},"aria-controls",[315,1176,465],{"class":335},[315,1178,737],{"class":453},[315,1180,1181],{"class":335},"listboxId",[315,1183,749],{"class":453},[315,1185,1186,1189,1191],{"class":317,"line":82},[315,1187,1188],{"class":331},"  aria-expanded",[315,1190,465],{"class":335},[315,1192,1193],{"class":453},"\"true\"\n",[315,1195,1196,1198,1200,1202,1204,1206],{"class":317,"line":36},[315,1197,819],{"class":335},[315,1199,822],{"class":331},[315,1201,465],{"class":335},[315,1203,737],{"class":453},[315,1205,829],{"class":335},[315,1207,749],{"class":453},[315,1209,1210],{"class":317,"line":58},[315,1211,1212],{"class":335},"\u002F>\n",[315,1214,1215],{"class":317,"line":439},[315,1216,1217],{"class":335},"\u003Cul :id=\"listboxId\" role=\"listbox\">\n",[315,1219,1220],{"class":317,"line":144},[315,1221,1222],{"class":335},"  \u003Cli\n",[315,1224,1225],{"class":317,"line":14},[315,1226,1227],{"class":335},"    v-for=\"option in options\"\n",[315,1229,1230],{"class":317,"line":488},[315,1231,1232],{"class":335},"    :id=\"option.id\"\n",[315,1234,1235],{"class":317,"line":42},[315,1236,1237],{"class":335},"    role=\"option\"\n",[315,1239,1240],{"class":317,"line":503},[315,1241,1242],{"class":335},"    :aria-selected=\"option.id === activeDescendant\"\n",[315,1244,1245],{"class":317,"line":533},[315,1246,1247],{"class":335},"  >\n",[315,1249,1250],{"class":317,"line":538},[315,1251,1252],{"class":335},"    {{ option.label }}\n",[315,1254,1255],{"class":317,"line":548},[315,1256,1257],{"class":335},"  \u003C\u002Fli>\n",[315,1259,1260],{"class":317,"line":558},[315,1261,1262],{"class":335},"\u003C\u002Ful>\n",[163,1264,1265,1266,1268,1269,1271,1272,1275,1276,1278],{},"Every option needs a unique ",[234,1267,871],{}," that matches what ",[234,1270,173],{}," references. When filtering changes the list, reset ",[234,1273,1274],{},"activeIndex"," — a stale reference to a removed ",[234,1277,871],{}," breaks the announcement chain.",[179,1280,1282],{"id":1281},"when-to-use-which","When to Use Which",[1284,1285,1286,1301],"table",{},[1287,1288,1289],"thead",{},[1290,1291,1292,1296,1299],"tr",{},[1293,1294,1295],"th",{},"Criteria",[1293,1297,1298],{},"Roving Tabindex",[1293,1300,173],{},[1302,1303,1304,1316,1327,1338,1349,1360,1371],"tbody",{},[1290,1305,1306,1310,1313],{},[1307,1308,1309],"td",{},"DOM focus",[1307,1311,1312],{},"Moves to each item",[1307,1314,1315],{},"Stays on container",[1290,1317,1318,1321,1324],{},[1307,1319,1320],{},"Best for",[1307,1322,1323],{},"Tabs, toolbars, radio groups, menu bars",[1307,1325,1326],{},"Comboboxes, searchable selects, listboxes with input",[1290,1328,1329,1332,1335],{},[1307,1330,1331],{},"Input field",[1307,1333,1334],{},"Not needed",[1307,1336,1337],{},"Required (or container acts as one)",[1290,1339,1340,1343,1346],{},[1307,1341,1342],{},"Browser support",[1307,1344,1345],{},"Universal",[1307,1347,1348],{},"Universal (but SR support varies)",[1290,1350,1351,1354,1357],{},[1307,1352,1353],{},"Complexity",[1307,1355,1356],{},"Lower",[1307,1358,1359],{},"Higher (need unique ids, careful attribute management)",[1290,1361,1362,1365,1368],{},[1307,1363,1364],{},"Filtering\u002Fsearch",[1307,1366,1367],{},"Awkward (focus + input conflict)",[1307,1369,1370],{},"Natural (focus stays on input)",[1290,1372,1373,1376,1379],{},[1307,1374,1375],{},"Screen reader",[1307,1377,1378],{},"Directly announced (element is focused)",[1307,1380,1381],{},"Announced via relationship (more indirection)",[256,1383,1385],{"id":1384},"decision-rule","Decision Rule",[163,1387,1388],{},"Three cases cover almost every widget:",[261,1390,1391,1400,1409],{},[193,1392,1393,1396,1397,1399],{},[167,1394,1395],{},"The widget has an input field"," that the user types into while navigating options → ",[167,1398,173],{},". This is the only pattern that lets the input stay focused while options are highlighted. Comboboxes, searchable selects, autocompletes — all use this.",[193,1401,1402,1405,1406,1408],{},[167,1403,1404],{},"Items are standalone interactive elements"," like tabs, toolbar buttons, or menu items → ",[167,1407,169],{},". Each item is a real focusable element, and there's no input to maintain focus on.",[193,1410,1411,1414,1415,1417],{},[167,1412,1413],{},"Not sure"," → ",[167,1416,169],{},". It's simpler to implement, has more consistent screen reader support, and covers most composite widget patterns.",[179,1419,1421],{"id":1420},"common-pitfalls","Common Pitfalls",[261,1423,1424,1436,1442,1470,1487],{},[193,1425,1426,1429,1430,1435],{},[167,1427,1428],{},"Forgetting"," ",[167,1431,1432],{},[234,1433,1434],{},"event.preventDefault()"," — Arrow keys scroll the page if not prevented. Every arrow key handler in a composite widget needs this.",[193,1437,1438,1441],{},[167,1439,1440],{},"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.",[193,1443,1444,1429,1447,1429,1452,1455,1456,1087,1459,1087,1462,1465,1466,1469],{},[167,1445,1446],{},"Missing",[167,1448,1449],{},[234,1450,1451],{},"role",[167,1453,1454],{},"attributes"," — Without ",[234,1457,1458],{},"role=\"tablist\"",[234,1460,1461],{},"role=\"tab\"",[234,1463,1464],{},"role=\"listbox\"",", and ",[234,1467,1468],{},"role=\"option\"",", screen readers can't identify the widget pattern and won't announce items correctly.",[193,1471,1472,1429,1475,1479,1480,1482,1483,1486],{},[167,1473,1474],{},"Stale",[167,1476,1477],{},[234,1478,173],{}," — When filtering changes the option list, the id referenced by ",[234,1481,173],{}," might no longer exist in the DOM. Always reset ",[234,1484,1485],{},"highlightedIndex"," (or recompute it) when the filtered list changes.",[193,1488,1489,1492,1493,1496],{},[167,1490,1491],{},"Not scrolling highlighted items into view"," — In long lists, arrowing through options can move the highlight off-screen. Call ",[234,1494,1495],{},"scrollIntoView({ block: 'nearest' })"," on the highlighted element after each navigation.",[179,1498,1500],{"id":1499},"resources","Resources",[190,1502,1503,1512,1519,1526],{},[193,1504,1505],{},[1506,1507,1511],"a",{"href":1508,"rel":1509},"https:\u002F\u002Fwww.w3.org\u002FWAI\u002FARIA\u002Fapg\u002Fpractices\u002Fkeyboard-interface\u002F",[1510],"nofollow","W3C APG: Developing a Keyboard Interface",[193,1513,1514],{},[1506,1515,1518],{"href":1516,"rel":1517},"https:\u002F\u002Fwww.w3.org\u002FWAI\u002FARIA\u002Fapg\u002Fpatterns\u002Ftabs\u002F",[1510],"W3C APG: Tabs Pattern",[193,1520,1521],{},[1506,1522,1525],{"href":1523,"rel":1524},"https:\u002F\u002Fwww.w3.org\u002FWAI\u002FARIA\u002Fapg\u002Fpatterns\u002Fcombobox\u002F",[1510],"W3C APG: Combobox Pattern",[193,1527,1528],{},[1506,1529,1532],{"href":1530,"rel":1531},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAccessibility\u002FARIA\u002FAttributes\u002Faria-activedescendant",[1510],"MDN: aria-activedescendant",[179,1534,1536],{"id":1535},"related-posts","Related Posts",[190,1538,1539],{},[193,1540,1541,1545],{},[1506,1542,1544],{"href":1543},"\u002Fblog\u002Fuse-announcer-nuxt","Announcing Dynamic Changes in Vue with aria-live"," — for changes that have no focus target at all",[1547,1548,1549],"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":311,"searchDepth":345,"depth":345,"links":1551},[1552,1553,1557,1561,1564,1565,1566],{"id":181,"depth":345,"text":182},{"id":228,"depth":345,"text":229,"children":1554},[1555,1556],{"id":258,"depth":126,"text":259},{"id":297,"depth":126,"text":298},{"id":857,"depth":345,"text":858,"children":1558},[1559,1560],{"id":881,"depth":126,"text":259},{"id":915,"depth":126,"text":298},{"id":1281,"depth":345,"text":1282,"children":1562},[1563],{"id":1384,"depth":126,"text":1385},{"id":1420,"depth":345,"text":1421},{"id":1499,"depth":345,"text":1500},{"id":1535,"depth":345,"text":1536},"2026-05-10","Roving tabindex vs aria-activedescendant — when to use each pattern, with interactive demos and implementation examples.","md",{},"\u002Fblog\u002Fkeyboard-navigation-composite-widgets",{"title":153,"description":1568},[16,56,1574],"ARIA","published","blog\u002Fkeyboard-navigation-composite-widgets","ACCESSIBILITY","HgwmKmDyR8S8KUB-CVhXxtGM7kFK-dTv8m-9BokwFsc",{"id":1580,"title":1581,"author":154,"body":1582,"date":1567,"description":3222,"extension":1569,"featured":11,"meta":3223,"navigation":138,"order":25,"path":3224,"seo":3225,"specs":3226,"status":1575,"stem":3229,"tag":3230,"__hash__":3231},"blog\u002Fblog\u002Ftypesense-schema-migrations-laravel.md","Typesense Schema Migrations in Laravel",{"type":156,"value":1583,"toc":3209},[1584,1587,1590,1593,1597,1604,1803,1806,1836,1846,1850,1857,1861,1892,1895,1912,1918,1922,1931,1935,1938,2418,2421,2442,2446,2449,2681,2688,2695,2701,2869,2880,2933,2936,2990,2994,3006,3016,3026,3036,3063,3072,3076,3137,3141,3146,3157,3163,3179,3182,3184,3206],[159,1585,1581],{"id":1586},"typesense-schema-migrations-in-laravel",[163,1588,1589],{},"Adding a field to a Typesense collection usually means: flush the collection, recreate it with the new schema, re-import every document. For a collection with hundreds of thousands of documents, that means minutes of downtime where search doesn't work.",[163,1591,1592],{},"I needed a better way. My order search index has hundreds of thousands of documents. Flushing and re-indexing takes time, and during that window, users can't search. So I built a migration-based approach that patches the live Typesense collection in place — no downtime, no data loss.",[179,1594,1596],{"id":1595},"how-laravel-scout-indexes-data","How Laravel Scout Indexes Data",[163,1598,1599,1600,1603],{},"Scout adds a ",[234,1601,1602],{},"Searchable"," trait to Eloquent models. The trait hooks into model lifecycle events — when a model is created, updated, or deleted, Scout automatically syncs it to Typesense.",[306,1605,1609],{"className":1606,"code":1607,"language":1608,"meta":311,"style":311},"language-php shiki shiki-themes github-dark github-dark","class Order extends Model\n{\n    use Searchable;\n\n    public function toSearchableArray(): array\n    {\n        return [\n            'id' => (string) $this->id,\n            'number' => $this->number,\n            'customer_name' => $this->customer?->name,\n            'status' => $this->status->value,\n            'total' => $this->total,\n            'created_at' => $this->created_at?->timestamp,\n        ];\n    }\n}\n","php",[234,1610,1611,1625,1630,1641,1645,1664,1669,1677,1702,1717,1737,1756,1770,1789,1794,1799],{"__ignoreMap":311},[315,1612,1613,1616,1619,1622],{"class":317,"line":141},[315,1614,1615],{"class":320},"class",[315,1617,1618],{"class":331}," Order",[315,1620,1621],{"class":320}," extends",[315,1623,1624],{"class":331}," Model\n",[315,1626,1627],{"class":317,"line":345},[315,1628,1629],{"class":335},"{\n",[315,1631,1632,1635,1638],{"class":317,"line":126},[315,1633,1634],{"class":320},"    use",[315,1636,1637],{"class":324}," Searchable",[315,1639,1640],{"class":335},";\n",[315,1642,1643],{"class":317,"line":54},[315,1644,365],{"emptyLinePlaceholder":138},[315,1646,1647,1650,1653,1656,1659,1661],{"class":317,"line":82},[315,1648,1649],{"class":320},"    public",[315,1651,1652],{"class":320}," function",[315,1654,1655],{"class":331}," toSearchableArray",[315,1657,1658],{"class":335},"()",[315,1660,383],{"class":320},[315,1662,1663],{"class":320}," array\n",[315,1665,1666],{"class":317,"line":36},[315,1667,1668],{"class":335},"    {\n",[315,1670,1671,1674],{"class":317,"line":58},[315,1672,1673],{"class":320},"        return",[315,1675,1676],{"class":335}," [\n",[315,1678,1679,1682,1685,1688,1691,1693,1696,1699],{"class":317,"line":439},[315,1680,1681],{"class":453},"            'id'",[315,1683,1684],{"class":320}," =>",[315,1686,1687],{"class":335}," (",[315,1689,1690],{"class":320},"string",[315,1692,477],{"class":335},[315,1694,1695],{"class":324},"$this",[315,1697,1698],{"class":320},"->",[315,1700,1701],{"class":335},"id,\n",[315,1703,1704,1707,1709,1712,1714],{"class":317,"line":144},[315,1705,1706],{"class":453},"            'number'",[315,1708,1684],{"class":320},[315,1710,1711],{"class":324}," $this",[315,1713,1698],{"class":320},[315,1715,1716],{"class":335},"number,\n",[315,1718,1719,1722,1724,1726,1728,1731,1734],{"class":317,"line":14},[315,1720,1721],{"class":453},"            'customer_name'",[315,1723,1684],{"class":320},[315,1725,1711],{"class":324},[315,1727,1698],{"class":320},[315,1729,1730],{"class":335},"customer",[315,1732,1733],{"class":320},"?->",[315,1735,1736],{"class":335},"name,\n",[315,1738,1739,1742,1744,1746,1748,1751,1753],{"class":317,"line":488},[315,1740,1741],{"class":453},"            'status'",[315,1743,1684],{"class":320},[315,1745,1711],{"class":324},[315,1747,1698],{"class":320},[315,1749,1750],{"class":335},"status",[315,1752,1698],{"class":320},[315,1754,1755],{"class":335},"value,\n",[315,1757,1758,1761,1763,1765,1767],{"class":317,"line":42},[315,1759,1760],{"class":453},"            'total'",[315,1762,1684],{"class":320},[315,1764,1711],{"class":324},[315,1766,1698],{"class":320},[315,1768,1769],{"class":335},"total,\n",[315,1771,1772,1775,1777,1779,1781,1784,1786],{"class":317,"line":503},[315,1773,1774],{"class":453},"            'created_at'",[315,1776,1684],{"class":320},[315,1778,1711],{"class":324},[315,1780,1698],{"class":320},[315,1782,1783],{"class":335},"created_at",[315,1785,1733],{"class":320},[315,1787,1788],{"class":335},"timestamp,\n",[315,1790,1791],{"class":317,"line":533},[315,1792,1793],{"class":335},"        ];\n",[315,1795,1796],{"class":317,"line":538},[315,1797,1798],{"class":335},"    }\n",[315,1800,1801],{"class":317,"line":548},[315,1802,711],{"class":335},[163,1804,1805],{},"The data flow:",[261,1807,1808,1818,1824,1830],{},[193,1809,1810,1813,1814,1817],{},[167,1811,1812],{},"Model saved"," → Scout's ",[234,1815,1816],{},"ModelObserver"," fires",[193,1819,1820,1823],{},[234,1821,1822],{},"shouldBeSearchable()"," → decides if the model belongs in the index",[193,1825,1826,1829],{},[234,1827,1828],{},"toSearchableArray()"," → serializes the model into a flat document",[193,1831,1832,1835],{},[234,1833,1834],{},"TypesenseEngine::update()"," → bulk upserts documents via Typesense's JSONL import API",[163,1837,1838,1839,1842,1843,1845],{},"For bulk imports, ",[234,1840,1841],{},"php artisan scout:import \"App\\Models\\Order\""," processes records in chunks of 500, calling ",[234,1844,1828],{}," on each and upserting them.",[179,1847,1849],{"id":1848},"the-problem-schema-changes","The Problem: Schema Changes",[163,1851,1852,1853,1856],{},"Typesense collections have a strict schema. Every field needs a name, type, and configuration before documents can include it. When I wanted to add a ",[234,1854,1855],{},"tags"," field to orders, I had two options:",[256,1858,1860],{"id":1859},"option-a-flush-and-recreate","Option A: Flush and Recreate",[306,1862,1866],{"className":1863,"code":1864,"language":1865,"meta":311,"style":311},"language-bash shiki shiki-themes github-dark github-dark","php artisan scout:flush \"App\\Models\\Order\"\nphp artisan scout:import \"App\\Models\\Order\"\n","bash",[234,1867,1868,1881],{"__ignoreMap":311},[315,1869,1870,1872,1875,1878],{"class":317,"line":141},[315,1871,1608],{"class":331},[315,1873,1874],{"class":453}," artisan",[315,1876,1877],{"class":453}," scout:flush",[315,1879,1880],{"class":453}," \"App\\Models\\Order\"\n",[315,1882,1883,1885,1887,1890],{"class":317,"line":345},[315,1884,1608],{"class":331},[315,1886,1874],{"class":453},[315,1888,1889],{"class":453}," scout:import",[315,1891,1880],{"class":453},[163,1893,1894],{},"This works, but:",[190,1896,1897,1903,1906,1909],{},[193,1898,1899,1902],{},[234,1900,1901],{},"scout:flush"," deletes the entire Typesense collection — not just documents, the collection itself",[193,1904,1905],{},"During re-import, search returns no results",[193,1907,1908],{},"Re-indexing 500k orders takes several minutes",[193,1910,1911],{},"If the import fails halfway, you're left with a partial index",[163,1913,1914,1917],{},[234,1915,1916],{},"scout:import"," alone (without flush) upserts documents into the existing collection — it doesn't require a flush. The flush here is only needed because the collection schema itself needs to change.",[256,1919,1921],{"id":1920},"option-b-patch-the-schema","Option B: Patch the Schema",[163,1923,1924,1925,1930],{},"Typesense's ",[1506,1926,1929],{"href":1927,"rel":1928},"https:\u002F\u002Ftypesense.org\u002Fdocs\u002F27.1\u002Fapi\u002Fcollections.html#update-or-alter-a-collection",[1510],"Collection API"," supports PATCH requests to add or remove fields from an existing collection without touching the documents already in it.",[179,1932,1934],{"id":1933},"typesenseschemaservice","TypesenseSchemaService",[163,1936,1937],{},"I built a service that wraps the Typesense PHP client with idempotent field-level operations:",[306,1939,1941],{"className":1606,"code":1940,"language":1608,"meta":311,"style":311},"class TypesenseSchemaService\n{\n    public static function isEnabled(): bool\n    {\n        return config('scout.driver') === 'typesense';\n    }\n\n    public function addField(string $collection, array $fieldDefinition): void\n    {\n        if ($this->fieldExists($collection, $fieldDefinition['name'])) {\n            return;\n        }\n\n        $this->client->collections[$this->prefixed($collection)]->update([\n            'fields' => [$fieldDefinition],\n        ]);\n    }\n\n    public function dropField(string $collection, string $fieldName): void\n    {\n        if (!$this->fieldExists($collection, $fieldName)) {\n            return;\n        }\n\n        $this->client->collections[$this->prefixed($collection)]->update([\n            'fields' => [['name' => $fieldName, 'drop' => true]],\n        ]);\n    }\n\n    private function fieldExists(string $collection, string $fieldName): bool\n    {\n        $schema = $this->client->collections[$this->prefixed($collection)]->retrieve();\n        return in_array($fieldName, array_column($schema['fields'], 'name'), true);\n    }\n\n    private function prefixed(string $collection): string\n    {\n        return config('scout.prefix') . $collection;\n    }\n}\n",[234,1942,1943,1950,1954,1973,1977,1998,2002,2006,2033,2037,2060,2067,2072,2076,2109,2119,2124,2128,2132,2156,2160,2178,2184,2188,2192,2218,2245,2249,2253,2257,2281,2285,2318,2351,2356,2361,2383,2388,2408,2413],{"__ignoreMap":311},[315,1944,1945,1947],{"class":317,"line":141},[315,1946,1615],{"class":320},[315,1948,1949],{"class":331}," TypesenseSchemaService\n",[315,1951,1952],{"class":317,"line":345},[315,1953,1629],{"class":335},[315,1955,1956,1958,1961,1963,1966,1968,1970],{"class":317,"line":126},[315,1957,1649],{"class":320},[315,1959,1960],{"class":320}," static",[315,1962,1652],{"class":320},[315,1964,1965],{"class":331}," isEnabled",[315,1967,1658],{"class":335},[315,1969,383],{"class":320},[315,1971,1972],{"class":320}," bool\n",[315,1974,1975],{"class":317,"line":54},[315,1976,1668],{"class":335},[315,1978,1979,1981,1984,1986,1989,1991,1993,1996],{"class":317,"line":82},[315,1980,1673],{"class":320},[315,1982,1983],{"class":331}," config",[315,1985,376],{"class":335},[315,1987,1988],{"class":453},"'scout.driver'",[315,1990,477],{"class":335},[315,1992,776],{"class":320},[315,1994,1995],{"class":453}," 'typesense'",[315,1997,1640],{"class":335},[315,1999,2000],{"class":317,"line":36},[315,2001,1798],{"class":335},[315,2003,2004],{"class":317,"line":58},[315,2005,365],{"emptyLinePlaceholder":138},[315,2007,2008,2010,2012,2015,2017,2019,2022,2025,2028,2030],{"class":317,"line":439},[315,2009,1649],{"class":320},[315,2011,1652],{"class":320},[315,2013,2014],{"class":331}," addField",[315,2016,376],{"class":335},[315,2018,1690],{"class":320},[315,2020,2021],{"class":335}," $collection, ",[315,2023,2024],{"class":320},"array",[315,2026,2027],{"class":335}," $fieldDefinition)",[315,2029,383],{"class":320},[315,2031,2032],{"class":320}," void\n",[315,2034,2035],{"class":317,"line":144},[315,2036,1668],{"class":335},[315,2038,2039,2042,2044,2046,2048,2051,2054,2057],{"class":317,"line":14},[315,2040,2041],{"class":320},"        if",[315,2043,1687],{"class":335},[315,2045,1695],{"class":324},[315,2047,1698],{"class":320},[315,2049,2050],{"class":331},"fieldExists",[315,2052,2053],{"class":335},"($collection, $fieldDefinition[",[315,2055,2056],{"class":453},"'name'",[315,2058,2059],{"class":335},"])) {\n",[315,2061,2062,2065],{"class":317,"line":488},[315,2063,2064],{"class":320},"            return",[315,2066,1640],{"class":335},[315,2068,2069],{"class":317,"line":42},[315,2070,2071],{"class":335},"        }\n",[315,2073,2074],{"class":317,"line":503},[315,2075,365],{"emptyLinePlaceholder":138},[315,2077,2078,2081,2083,2086,2088,2091,2093,2095,2098,2101,2103,2106],{"class":317,"line":533},[315,2079,2080],{"class":324},"        $this",[315,2082,1698],{"class":320},[315,2084,2085],{"class":335},"client",[315,2087,1698],{"class":320},[315,2089,2090],{"class":335},"collections[",[315,2092,1695],{"class":324},[315,2094,1698],{"class":320},[315,2096,2097],{"class":331},"prefixed",[315,2099,2100],{"class":335},"($collection)]",[315,2102,1698],{"class":320},[315,2104,2105],{"class":331},"update",[315,2107,2108],{"class":335},"([\n",[315,2110,2111,2114,2116],{"class":317,"line":538},[315,2112,2113],{"class":453},"            'fields'",[315,2115,1684],{"class":320},[315,2117,2118],{"class":335}," [$fieldDefinition],\n",[315,2120,2121],{"class":317,"line":548},[315,2122,2123],{"class":335},"        ]);\n",[315,2125,2126],{"class":317,"line":558},[315,2127,1798],{"class":335},[315,2129,2130],{"class":317,"line":563},[315,2131,365],{"emptyLinePlaceholder":138},[315,2133,2134,2136,2138,2141,2143,2145,2147,2149,2152,2154],{"class":317,"line":573},[315,2135,1649],{"class":320},[315,2137,1652],{"class":320},[315,2139,2140],{"class":331}," dropField",[315,2142,376],{"class":335},[315,2144,1690],{"class":320},[315,2146,2021],{"class":335},[315,2148,1690],{"class":320},[315,2150,2151],{"class":335}," $fieldName)",[315,2153,383],{"class":320},[315,2155,2032],{"class":320},[315,2157,2158],{"class":317,"line":590},[315,2159,1668],{"class":335},[315,2161,2162,2164,2166,2169,2171,2173,2175],{"class":317,"line":595},[315,2163,2041],{"class":320},[315,2165,1687],{"class":335},[315,2167,2168],{"class":320},"!",[315,2170,1695],{"class":324},[315,2172,1698],{"class":320},[315,2174,2050],{"class":331},[315,2176,2177],{"class":335},"($collection, $fieldName)) {\n",[315,2179,2180,2182],{"class":317,"line":605},[315,2181,2064],{"class":320},[315,2183,1640],{"class":335},[315,2185,2186],{"class":317,"line":615},[315,2187,2071],{"class":335},[315,2189,2190],{"class":317,"line":627},[315,2191,365],{"emptyLinePlaceholder":138},[315,2193,2194,2196,2198,2200,2202,2204,2206,2208,2210,2212,2214,2216],{"class":317,"line":638},[315,2195,2080],{"class":324},[315,2197,1698],{"class":320},[315,2199,2085],{"class":335},[315,2201,1698],{"class":320},[315,2203,2090],{"class":335},[315,2205,1695],{"class":324},[315,2207,1698],{"class":320},[315,2209,2097],{"class":331},[315,2211,2100],{"class":335},[315,2213,1698],{"class":320},[315,2215,2105],{"class":331},[315,2217,2108],{"class":335},[315,2219,2220,2222,2224,2227,2229,2231,2234,2237,2239,2242],{"class":317,"line":644},[315,2221,2113],{"class":453},[315,2223,1684],{"class":320},[315,2225,2226],{"class":335}," [[",[315,2228,2056],{"class":453},[315,2230,1684],{"class":320},[315,2232,2233],{"class":335}," $fieldName, ",[315,2235,2236],{"class":453},"'drop'",[315,2238,1684],{"class":320},[315,2240,2241],{"class":324}," true",[315,2243,2244],{"class":335},"]],\n",[315,2246,2247],{"class":317,"line":650},[315,2248,2123],{"class":335},[315,2250,2251],{"class":317,"line":655},[315,2252,1798],{"class":335},[315,2254,2255],{"class":317,"line":671},[315,2256,365],{"emptyLinePlaceholder":138},[315,2258,2259,2262,2264,2267,2269,2271,2273,2275,2277,2279],{"class":317,"line":681},[315,2260,2261],{"class":320},"    private",[315,2263,1652],{"class":320},[315,2265,2266],{"class":331}," fieldExists",[315,2268,376],{"class":335},[315,2270,1690],{"class":320},[315,2272,2021],{"class":335},[315,2274,1690],{"class":320},[315,2276,2151],{"class":335},[315,2278,383],{"class":320},[315,2280,1972],{"class":320},[315,2282,2283],{"class":317,"line":692},[315,2284,1668],{"class":335},[315,2286,2287,2290,2292,2294,2296,2298,2300,2302,2304,2306,2308,2310,2312,2315],{"class":317,"line":703},[315,2288,2289],{"class":335},"        $schema ",[315,2291,465],{"class":320},[315,2293,1711],{"class":324},[315,2295,1698],{"class":320},[315,2297,2085],{"class":335},[315,2299,1698],{"class":320},[315,2301,2090],{"class":335},[315,2303,1695],{"class":324},[315,2305,1698],{"class":320},[315,2307,2097],{"class":331},[315,2309,2100],{"class":335},[315,2311,1698],{"class":320},[315,2313,2314],{"class":331},"retrieve",[315,2316,2317],{"class":335},"();\n",[315,2319,2320,2322,2325,2328,2331,2334,2337,2340,2342,2345,2348],{"class":317,"line":708},[315,2321,1673],{"class":320},[315,2323,2324],{"class":324}," in_array",[315,2326,2327],{"class":335},"($fieldName, ",[315,2329,2330],{"class":324},"array_column",[315,2332,2333],{"class":335},"($schema[",[315,2335,2336],{"class":453},"'fields'",[315,2338,2339],{"class":335},"], ",[315,2341,2056],{"class":453},[315,2343,2344],{"class":335},"), ",[315,2346,2347],{"class":324},"true",[315,2349,2350],{"class":335},");\n",[315,2352,2354],{"class":317,"line":2353},34,[315,2355,1798],{"class":335},[315,2357,2359],{"class":317,"line":2358},35,[315,2360,365],{"emptyLinePlaceholder":138},[315,2362,2364,2366,2368,2371,2373,2375,2378,2380],{"class":317,"line":2363},36,[315,2365,2261],{"class":320},[315,2367,1652],{"class":320},[315,2369,2370],{"class":331}," prefixed",[315,2372,376],{"class":335},[315,2374,1690],{"class":320},[315,2376,2377],{"class":335}," $collection)",[315,2379,383],{"class":320},[315,2381,2382],{"class":320}," string\n",[315,2384,2386],{"class":317,"line":2385},37,[315,2387,1668],{"class":335},[315,2389,2391,2393,2395,2397,2400,2402,2405],{"class":317,"line":2390},38,[315,2392,1673],{"class":320},[315,2394,1983],{"class":331},[315,2396,376],{"class":335},[315,2398,2399],{"class":453},"'scout.prefix'",[315,2401,477],{"class":335},[315,2403,2404],{"class":320},".",[315,2406,2407],{"class":335}," $collection;\n",[315,2409,2411],{"class":317,"line":2410},39,[315,2412,1798],{"class":335},[315,2414,2416],{"class":317,"line":2415},40,[315,2417,711],{"class":335},[163,2419,2420],{},"Two things matter:",[261,2422,2423,2433],{},[193,2424,2425,2428,2429,2432],{},[167,2426,2427],{},"Idempotent"," — ",[234,2430,2431],{},"addField"," checks if the field exists before adding. Running the same migration twice doesn't fail.",[193,2434,2435,2428,2438,2441],{},[167,2436,2437],{},"Reversible",[234,2439,2440],{},"dropField"," removes a field cleanly. Standard migration rollback.",[179,2443,2445],{"id":2444},"the-migration","The Migration",[163,2447,2448],{},"The key insight: treat Typesense schema changes like database schema changes. Use Laravel migrations.",[306,2450,2452],{"className":1606,"code":2451,"language":1608,"meta":311,"style":311},"return new class extends Migration\n{\n    public function up(): void\n    {\n        if (!TypesenseSchemaService::isEnabled()) {\n            return;\n        }\n\n        TypesenseSchemaService::make()->addField('orders', [\n            'name' => 'tags',\n            'type' => 'string[]',\n            'optional' => true,\n        ]);\n    }\n\n    public function down(): void\n    {\n        if (!TypesenseSchemaService::isEnabled()) {\n            return;\n        }\n\n        TypesenseSchemaService::make()->dropField('orders', 'tags');\n    }\n};\n",[234,2453,2454,2470,2474,2489,2493,2512,2518,2522,2526,2550,2563,2575,2586,2590,2594,2598,2613,2617,2633,2639,2643,2647,2672,2676],{"__ignoreMap":311},[315,2455,2456,2459,2462,2465,2467],{"class":317,"line":141},[315,2457,2458],{"class":320},"return",[315,2460,2461],{"class":320}," new",[315,2463,2464],{"class":320}," class",[315,2466,1621],{"class":320},[315,2468,2469],{"class":331}," Migration\n",[315,2471,2472],{"class":317,"line":345},[315,2473,1629],{"class":335},[315,2475,2476,2478,2480,2483,2485,2487],{"class":317,"line":126},[315,2477,1649],{"class":320},[315,2479,1652],{"class":320},[315,2481,2482],{"class":331}," up",[315,2484,1658],{"class":335},[315,2486,383],{"class":320},[315,2488,2032],{"class":320},[315,2490,2491],{"class":317,"line":54},[315,2492,1668],{"class":335},[315,2494,2495,2497,2499,2501,2503,2506,2509],{"class":317,"line":82},[315,2496,2041],{"class":320},[315,2498,1687],{"class":335},[315,2500,2168],{"class":320},[315,2502,1934],{"class":324},[315,2504,2505],{"class":320},"::",[315,2507,2508],{"class":331},"isEnabled",[315,2510,2511],{"class":335},"()) {\n",[315,2513,2514,2516],{"class":317,"line":36},[315,2515,2064],{"class":320},[315,2517,1640],{"class":335},[315,2519,2520],{"class":317,"line":58},[315,2521,2071],{"class":335},[315,2523,2524],{"class":317,"line":439},[315,2525,365],{"emptyLinePlaceholder":138},[315,2527,2528,2531,2533,2536,2538,2540,2542,2544,2547],{"class":317,"line":144},[315,2529,2530],{"class":324},"        TypesenseSchemaService",[315,2532,2505],{"class":320},[315,2534,2535],{"class":331},"make",[315,2537,1658],{"class":335},[315,2539,1698],{"class":320},[315,2541,2431],{"class":331},[315,2543,376],{"class":335},[315,2545,2546],{"class":453},"'orders'",[315,2548,2549],{"class":335},", [\n",[315,2551,2552,2555,2557,2560],{"class":317,"line":14},[315,2553,2554],{"class":453},"            'name'",[315,2556,1684],{"class":320},[315,2558,2559],{"class":453}," 'tags'",[315,2561,2562],{"class":335},",\n",[315,2564,2565,2568,2570,2573],{"class":317,"line":488},[315,2566,2567],{"class":453},"            'type'",[315,2569,1684],{"class":320},[315,2571,2572],{"class":453}," 'string[]'",[315,2574,2562],{"class":335},[315,2576,2577,2580,2582,2584],{"class":317,"line":42},[315,2578,2579],{"class":453},"            'optional'",[315,2581,1684],{"class":320},[315,2583,2241],{"class":324},[315,2585,2562],{"class":335},[315,2587,2588],{"class":317,"line":503},[315,2589,2123],{"class":335},[315,2591,2592],{"class":317,"line":533},[315,2593,1798],{"class":335},[315,2595,2596],{"class":317,"line":538},[315,2597,365],{"emptyLinePlaceholder":138},[315,2599,2600,2602,2604,2607,2609,2611],{"class":317,"line":548},[315,2601,1649],{"class":320},[315,2603,1652],{"class":320},[315,2605,2606],{"class":331}," down",[315,2608,1658],{"class":335},[315,2610,383],{"class":320},[315,2612,2032],{"class":320},[315,2614,2615],{"class":317,"line":558},[315,2616,1668],{"class":335},[315,2618,2619,2621,2623,2625,2627,2629,2631],{"class":317,"line":563},[315,2620,2041],{"class":320},[315,2622,1687],{"class":335},[315,2624,2168],{"class":320},[315,2626,1934],{"class":324},[315,2628,2505],{"class":320},[315,2630,2508],{"class":331},[315,2632,2511],{"class":335},[315,2634,2635,2637],{"class":317,"line":573},[315,2636,2064],{"class":320},[315,2638,1640],{"class":335},[315,2640,2641],{"class":317,"line":590},[315,2642,2071],{"class":335},[315,2644,2645],{"class":317,"line":595},[315,2646,365],{"emptyLinePlaceholder":138},[315,2648,2649,2651,2653,2655,2657,2659,2661,2663,2665,2667,2670],{"class":317,"line":605},[315,2650,2530],{"class":324},[315,2652,2505],{"class":320},[315,2654,2535],{"class":331},[315,2656,1658],{"class":335},[315,2658,1698],{"class":320},[315,2660,2440],{"class":331},[315,2662,376],{"class":335},[315,2664,2546],{"class":453},[315,2666,1087],{"class":335},[315,2668,2669],{"class":453},"'tags'",[315,2671,2350],{"class":335},[315,2673,2674],{"class":317,"line":615},[315,2675,1798],{"class":335},[315,2677,2678],{"class":317,"line":627},[315,2679,2680],{"class":335},"};\n",[163,2682,2683,2684,2687],{},"The ",[234,2685,2686],{},"isEnabled()"," guard is important — in test environments or local setups without Typesense, the migration is a no-op.",[163,2689,2690,2691,2694],{},"This deploys with ",[234,2692,2693],{},"php artisan migrate",", right alongside your database migrations. No separate deployment step, no manual commands.",[163,2696,2697,2698,2700],{},"The model's ",[234,2699,1828],{}," needs to include the new field:",[306,2702,2704],{"className":1606,"code":2703,"language":1608,"meta":311,"style":311},"public function toSearchableArray(): array\n{\n    return [\n        'id' => (string) $this->id,\n        'number' => $this->number,\n        'customer_name' => $this->customer?->name,\n        'status' => $this->status->value,\n        'total' => $this->total,\n        'created_at' => $this->created_at?->timestamp,\n        'tags' => $this->tags->pluck('name')->all(),\n    ];\n}\n",[234,2705,2706,2721,2725,2732,2751,2764,2781,2798,2811,2828,2860,2865],{"__ignoreMap":311},[315,2707,2708,2711,2713,2715,2717,2719],{"class":317,"line":141},[315,2709,2710],{"class":320},"public",[315,2712,1652],{"class":320},[315,2714,1655],{"class":331},[315,2716,1658],{"class":335},[315,2718,383],{"class":320},[315,2720,1663],{"class":320},[315,2722,2723],{"class":317,"line":345},[315,2724,1629],{"class":335},[315,2726,2727,2730],{"class":317,"line":126},[315,2728,2729],{"class":320},"    return",[315,2731,1676],{"class":335},[315,2733,2734,2737,2739,2741,2743,2745,2747,2749],{"class":317,"line":54},[315,2735,2736],{"class":453},"        'id'",[315,2738,1684],{"class":320},[315,2740,1687],{"class":335},[315,2742,1690],{"class":320},[315,2744,477],{"class":335},[315,2746,1695],{"class":324},[315,2748,1698],{"class":320},[315,2750,1701],{"class":335},[315,2752,2753,2756,2758,2760,2762],{"class":317,"line":82},[315,2754,2755],{"class":453},"        'number'",[315,2757,1684],{"class":320},[315,2759,1711],{"class":324},[315,2761,1698],{"class":320},[315,2763,1716],{"class":335},[315,2765,2766,2769,2771,2773,2775,2777,2779],{"class":317,"line":36},[315,2767,2768],{"class":453},"        'customer_name'",[315,2770,1684],{"class":320},[315,2772,1711],{"class":324},[315,2774,1698],{"class":320},[315,2776,1730],{"class":335},[315,2778,1733],{"class":320},[315,2780,1736],{"class":335},[315,2782,2783,2786,2788,2790,2792,2794,2796],{"class":317,"line":58},[315,2784,2785],{"class":453},"        'status'",[315,2787,1684],{"class":320},[315,2789,1711],{"class":324},[315,2791,1698],{"class":320},[315,2793,1750],{"class":335},[315,2795,1698],{"class":320},[315,2797,1755],{"class":335},[315,2799,2800,2803,2805,2807,2809],{"class":317,"line":439},[315,2801,2802],{"class":453},"        'total'",[315,2804,1684],{"class":320},[315,2806,1711],{"class":324},[315,2808,1698],{"class":320},[315,2810,1769],{"class":335},[315,2812,2813,2816,2818,2820,2822,2824,2826],{"class":317,"line":144},[315,2814,2815],{"class":453},"        'created_at'",[315,2817,1684],{"class":320},[315,2819,1711],{"class":324},[315,2821,1698],{"class":320},[315,2823,1783],{"class":335},[315,2825,1733],{"class":320},[315,2827,1788],{"class":335},[315,2829,2830,2833,2835,2837,2839,2841,2843,2846,2848,2850,2852,2854,2857],{"class":317,"line":14},[315,2831,2832],{"class":453},"        'tags'",[315,2834,1684],{"class":320},[315,2836,1711],{"class":324},[315,2838,1698],{"class":320},[315,2840,1855],{"class":335},[315,2842,1698],{"class":320},[315,2844,2845],{"class":331},"pluck",[315,2847,376],{"class":335},[315,2849,2056],{"class":453},[315,2851,285],{"class":335},[315,2853,1698],{"class":320},[315,2855,2856],{"class":331},"all",[315,2858,2859],{"class":335},"(),\n",[315,2861,2862],{"class":317,"line":488},[315,2863,2864],{"class":335},"    ];\n",[315,2866,2867],{"class":317,"line":42},[315,2868,711],{"class":335},[163,2870,2871,2872,2875,2876,2879],{},"Accessing ",[234,2873,2874],{},"$this->tags"," triggers a query per model. During bulk indexing, this causes N+1 queries. Use ",[234,2877,2878],{},"makeAllSearchableUsing()"," to eager-load relationships before indexing:",[306,2881,2883],{"className":1606,"code":2882,"language":1608,"meta":311,"style":311},"public function makeAllSearchableUsing(Builder $query): Builder\n{\n    return $query->with('tags');\n}\n",[234,2884,2885,2907,2911,2929],{"__ignoreMap":311},[315,2886,2887,2889,2891,2894,2896,2899,2902,2904],{"class":317,"line":141},[315,2888,2710],{"class":320},[315,2890,1652],{"class":320},[315,2892,2893],{"class":331}," makeAllSearchableUsing",[315,2895,376],{"class":335},[315,2897,2898],{"class":324},"Builder",[315,2900,2901],{"class":335}," $query)",[315,2903,383],{"class":320},[315,2905,2906],{"class":324}," Builder\n",[315,2908,2909],{"class":317,"line":345},[315,2910,1629],{"class":335},[315,2912,2913,2915,2918,2920,2923,2925,2927],{"class":317,"line":126},[315,2914,2729],{"class":320},[315,2916,2917],{"class":335}," $query",[315,2919,1698],{"class":320},[315,2921,2922],{"class":331},"with",[315,2924,376],{"class":335},[315,2926,2669],{"class":453},[315,2928,2350],{"class":335},[315,2930,2931],{"class":317,"line":54},[315,2932,711],{"class":335},[163,2934,2935],{},"And if the new field should be searchable, update the Scout config:",[306,2937,2939],{"className":1606,"code":2938,"language":1608,"meta":311,"style":311},"\u002F\u002F config\u002Fscout.php → typesense.model-settings\nOrder::class => [\n    'search-parameters' => [\n        'query_by' => 'number,customer_name,tags',\n    ],\n],\n",[234,2940,2941,2947,2959,2968,2980,2985],{"__ignoreMap":311},[315,2942,2943],{"class":317,"line":141},[315,2944,2946],{"class":2945},"sJ8bj","\u002F\u002F config\u002Fscout.php → typesense.model-settings\n",[315,2948,2949,2952,2955,2957],{"class":317,"line":345},[315,2950,2951],{"class":324},"Order",[315,2953,2954],{"class":320},"::class",[315,2956,1684],{"class":320},[315,2958,1676],{"class":335},[315,2960,2961,2964,2966],{"class":317,"line":126},[315,2962,2963],{"class":453},"    'search-parameters'",[315,2965,1684],{"class":320},[315,2967,1676],{"class":335},[315,2969,2970,2973,2975,2978],{"class":317,"line":54},[315,2971,2972],{"class":453},"        'query_by'",[315,2974,1684],{"class":320},[315,2976,2977],{"class":453}," 'number,customer_name,tags'",[315,2979,2562],{"class":335},[315,2981,2982],{"class":317,"line":82},[315,2983,2984],{"class":335},"    ],\n",[315,2986,2987],{"class":317,"line":36},[315,2988,2989],{"class":335},"],\n",[179,2991,2993],{"id":2992},"what-happens-after-the-migration","What Happens After the Migration",[163,2995,2996,2997,3001,3002,3005],{},"The schema PATCH is a ",[1506,2998,3000],{"href":1927,"rel":2999},[1510],"synchronous blocking operation"," — incoming writes to the collection wait until it completes, but ",[167,3003,3004],{},"search queries continue without interruption",". For adding an optional field, this completes in milliseconds.",[163,3007,3008,3009,3011,3012,3015],{},"After the migration, the ",[234,3010,1855],{}," field exists in the Typesense schema but no documents have values for it yet. Because the field is ",[234,3013,3014],{},"optional: true",", existing documents remain valid and searchable.",[163,3017,3018,3019,3022,3023,3025],{},"Documents get the new field naturally through Scout's model observer — whenever an order is saved and ",[234,3020,3021],{},"searchIndexShouldBeUpdated()"," returns true, Scout calls ",[234,3024,1828],{}," and the new field is included. Over time, the index fills in without any bulk operation.",[163,3027,3028,3029,3032,3033,3035],{},"If you need the field populated faster for a subset of records, you can use Scout's ",[234,3030,3031],{},"searchable()"," method, which re-indexes models by calling ",[234,3034,1828],{}," and upserting the result to Typesense:",[306,3037,3039],{"className":1606,"code":3038,"language":1608,"meta":311,"style":311},"Order::whereHas('tags')->searchable();\n",[234,3040,3041],{"__ignoreMap":311},[315,3042,3043,3045,3047,3050,3052,3054,3056,3058,3061],{"class":317,"line":141},[315,3044,2951],{"class":324},[315,3046,2505],{"class":320},[315,3048,3049],{"class":331},"whereHas",[315,3051,376],{"class":335},[315,3053,2669],{"class":453},[315,3055,285],{"class":335},[315,3057,1698],{"class":320},[315,3059,3060],{"class":331},"searchable",[315,3062,2317],{"class":335},[163,3064,3065,3066,3071],{},"This re-indexes only the orders that actually have tags — not the entire collection. Typesense ",[1506,3067,3070],{"href":3068,"rel":3069},"https:\u002F\u002Ftypesense.org\u002Fdocs\u002F27.1\u002Fapi\u002Fdocuments.html#configure-batch-size",[1510],"interleaves imports and search queries"," (processing 40 documents, then servicing the search queue, then the next batch), so search stays responsive throughout.",[179,3073,3075],{"id":3074},"comparison","Comparison",[1284,3077,3078,3097],{},[1287,3079,3080],{},[1290,3081,3082,3085,3088,3091,3094],{},[1293,3083,3084],{},"Approach",[1293,3086,3087],{},"Downtime",[1293,3089,3090],{},"Data Loss Risk",[1293,3092,3093],{},"Deployment",[1293,3095,3096],{},"Rollback",[1302,3098,3099,3116],{},[1290,3100,3101,3104,3107,3110,3113],{},[1307,3102,3103],{},"Flush + import",[1307,3105,3106],{},"Minutes",[1307,3108,3109],{},"High (partial re-index)",[1307,3111,3112],{},"Manual",[1307,3114,3115],{},"Re-run flush + import",[1290,3117,3118,3121,3124,3127,3132],{},[1307,3119,3120],{},"Schema migration",[1307,3122,3123],{},"Reads unaffected; writes briefly blocked during PATCH",[1307,3125,3126],{},"None",[1307,3128,3129],{},[234,3130,3131],{},"migrate",[1307,3133,3134],{},[234,3135,3136],{},"migrate:rollback",[179,3138,3140],{"id":3139},"when-to-use-each-approach","When to Use Each Approach",[163,3142,3143,3145],{},[167,3144,3120],{}," works when you're:",[190,3147,3148,3151,3154],{},[193,3149,3150],{},"Adding optional fields",[193,3152,3153],{},"Removing fields",[193,3155,3156],{},"Changing field configuration (e.g., facet or index flags)",[163,3158,3159,3162],{},[167,3160,3161],{},"Flush and recreate"," is still necessary when you:",[190,3164,3165,3173,3176],{},[193,3166,3167,3168,1414,3170,285],{},"Change a field's type (e.g., ",[234,3169,1690],{},[234,3171,3172],{},"int32",[193,3174,3175],{},"Rename a field (Typesense doesn't support renames — add new, backfill, drop old)",[193,3177,3178],{},"Restructure the entire schema",[163,3180,3181],{},"For most day-to-day feature work — adding a new searchable field, making something filterable, indexing a new relationship — the migration approach keeps search running while you deploy.",[179,3183,1500],{"id":1499},[190,3185,3186,3192,3199],{},[193,3187,3188],{},[1506,3189,3191],{"href":1927,"rel":3190},[1510],"Typesense: Update or Alter a Collection",[193,3193,3194],{},[1506,3195,3198],{"href":3196,"rel":3197},"https:\u002F\u002Flaravel.com\u002Fdocs\u002Fscout",[1510],"Laravel Scout Documentation",[193,3200,3201],{},[1506,3202,3205],{"href":3203,"rel":3204},"https:\u002F\u002Fgithub.com\u002Ftypesense\u002Ftypesense-php",[1510],"Typesense PHP Client",[1547,3207,3208],{},"html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}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 .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":311,"searchDepth":345,"depth":345,"links":3210},[3211,3212,3216,3217,3218,3219,3220,3221],{"id":1595,"depth":345,"text":1596},{"id":1848,"depth":345,"text":1849,"children":3213},[3214,3215],{"id":1859,"depth":126,"text":1860},{"id":1920,"depth":126,"text":1921},{"id":1933,"depth":345,"text":1934},{"id":2444,"depth":345,"text":2445},{"id":2992,"depth":345,"text":2993},{"id":3074,"depth":345,"text":3075},{"id":3139,"depth":345,"text":3140},{"id":1499,"depth":345,"text":1500},"How to patch Typesense collections at deploy time using Laravel migrations instead of flushing and re-indexing.",{},"\u002Fblog\u002Ftypesense-schema-migrations-laravel",{"title":1581,"description":3222},[18,3227,3228],"TYPESENSE","SCOUT","blog\u002Ftypesense-schema-migrations-laravel","BACKEND","4G53TYRgKCpjMpqaIE9LYEvSk5sFoix5eFvhOl6NeIA",{"id":3233,"title":1544,"author":154,"body":3234,"date":3957,"description":3958,"extension":1569,"featured":138,"meta":3959,"navigation":138,"order":345,"path":1543,"seo":3960,"specs":3961,"status":1575,"stem":3962,"tag":1577,"__hash__":3963},"blog\u002Fblog\u002Fuse-announcer-nuxt.md",{"type":156,"value":3235,"toc":3947},[3236,3239,3242,3248,3252,3255,3258,3261,3265,3271,3316,3319,3345,3351,3355,3358,3424,3427,3431,3437,3443,3449,3455,3461,3473,3477,3483,3489,3712,3719,3858,3862,3865,3875,3892,3898,3903,3905,3944],[159,3237,1544],{"id":3238},"announcing-dynamic-changes-in-vue-with-aria-live",[163,3240,3241],{},"A Vue app re-renders reactively — a ref changes, the DOM updates, the user sees it. Screen readers don't. They need to be told explicitly when something important changed.",[163,3243,3244,3247],{},[234,3245,3246],{},"aria-live"," is the attribute that does this. It tells the browser: \"When this element's content changes, announce it.\"",[179,3249,3251],{"id":3250},"the-problem","The Problem",[163,3253,3254],{},"A screen reader builds a virtual representation of the page. It announces content when the user navigates to it — Tab to a button, arrow through a list, read the next paragraph. But it doesn't re-read the entire page when something changes. Why would it? On a static page, nothing changes unless the user interacts.",[163,3256,3257],{},"SPAs break this assumption. Route changes swap the entire view. Form submissions show success messages. Search inputs filter a list in real-time. Toast notifications pop up and disappear. None of these trigger a screen reader announcement by default.",[163,3259,3260],{},"The user submits a form, hears nothing, and has no idea whether it worked.",[179,3262,3264],{"id":3263},"how-aria-live-works","How aria-live Works",[163,3266,3267,3268,3270],{},"An ",[234,3269,3246],{}," region is an element the browser watches for content mutations. When text inside it changes, the screen reader announces the new content — even if the user's focus is somewhere else entirely.",[306,3272,3276],{"className":3273,"code":3274,"language":3275,"meta":311,"style":311},"language-html shiki shiki-themes github-dark github-dark","\u003Cdiv aria-live=\"polite\" aria-atomic=\"true\">\n  \u003C!-- When text changes here, screen readers announce it -->\n\u003C\u002Fdiv>\n","html",[234,3277,3278,3303,3308],{"__ignoreMap":311},[315,3279,3280,3282,3285,3288,3290,3293,3296,3298,3301],{"class":317,"line":141},[315,3281,723],{"class":335},[315,3283,3284],{"class":726},"div",[315,3286,3287],{"class":331}," aria-live",[315,3289,465],{"class":335},[315,3291,3292],{"class":453},"\"polite\"",[315,3294,3295],{"class":331}," aria-atomic",[315,3297,465],{"class":335},[315,3299,3300],{"class":453},"\"true\"",[315,3302,836],{"class":335},[315,3304,3305],{"class":317,"line":345},[315,3306,3307],{"class":2945},"  \u003C!-- When text changes here, screen readers announce it -->\n",[315,3309,3310,3312,3314],{"class":317,"line":126},[315,3311,846],{"class":335},[315,3313,3284],{"class":726},[315,3315,836],{"class":335},[163,3317,3318],{},"Three politeness levels:",[190,3320,3321,3329,3337],{},[193,3322,3323,3328],{},[167,3324,3325],{},[234,3326,3327],{},"polite"," — waits until the screen reader finishes its current announcement, then speaks. Use this for most things: status updates, search results counts, non-critical feedback.",[193,3330,3331,3336],{},[167,3332,3333],{},[234,3334,3335],{},"assertive"," — interrupts whatever the screen reader is currently saying. Reserved for errors and time-sensitive information.",[193,3338,3339,3344],{},[167,3340,3341],{},[234,3342,3343],{},"off"," — the default. Changes are not announced.",[163,3346,3347,3350],{},[234,3348,3349],{},"aria-atomic=\"true\""," tells the screen reader to announce the entire region content, not just the changed text node. Without it, partial updates can produce confusing fragments.",[256,3352,3354],{"id":3353},"the-empty-then-fill-trick","The Empty-Then-Fill Trick",[163,3356,3357],{},"There's a browser quirk: if you set the same text twice in a row, some screen readers won't announce it the second time (the content didn't \"change\"). The workaround is to clear the region first, then set the new text on the next tick:",[306,3359,3361],{"className":308,"code":3360,"language":310,"meta":311,"style":311},"function announce(message: string) {\n  region.textContent = ''\n  requestAnimationFrame(() => {\n    region.textContent = message\n  })\n}\n",[234,3362,3363,3382,3392,3405,3415,3420],{"__ignoreMap":311},[315,3364,3365,3367,3370,3372,3375,3377,3380],{"class":317,"line":141},[315,3366,370],{"class":320},[315,3368,3369],{"class":331}," announce",[315,3371,376],{"class":335},[315,3373,3374],{"class":379},"message",[315,3376,383],{"class":320},[315,3378,3379],{"class":324}," string",[315,3381,389],{"class":335},[315,3383,3384,3387,3389],{"class":317,"line":345},[315,3385,3386],{"class":335},"  region.textContent ",[315,3388,465],{"class":320},[315,3390,3391],{"class":453}," ''\n",[315,3393,3394,3397,3399,3402],{"class":317,"line":126},[315,3395,3396],{"class":331},"  requestAnimationFrame",[315,3398,960],{"class":335},[315,3400,3401],{"class":320},"=>",[315,3403,3404],{"class":335}," {\n",[315,3406,3407,3410,3412],{"class":317,"line":54},[315,3408,3409],{"class":335},"    region.textContent ",[315,3411,465],{"class":320},[315,3413,3414],{"class":335}," message\n",[315,3416,3417],{"class":317,"line":82},[315,3418,3419],{"class":335},"  })\n",[315,3421,3422],{"class":317,"line":36},[315,3423,711],{"class":335},[163,3425,3426],{},"This forces a mutation the browser recognizes as a change, guaranteeing the announcement fires.",[179,3428,3430],{"id":3429},"common-use-cases","Common Use Cases",[163,3432,3433,3436],{},[167,3434,3435],{},"Route changes"," — SPAs don't trigger the screen reader's \"new page loaded\" announcement. A live region can announce the new page title after navigation.",[163,3438,3439,3442],{},[167,3440,3441],{},"Form feedback"," — \"Message sent\", \"3 validation errors\", \"Saving...\". The user needs confirmation that their action had an effect.",[163,3444,3445,3448],{},[167,3446,3447],{},"Live search results"," — \"Found 12 results\" as the user types. Without this, filtering a list is completely silent.",[163,3450,3451,3454],{},[167,3452,3453],{},"Async operations"," — \"Loading\", then \"Data loaded\" or \"Request failed\". Without these, the user has no idea the app is working.",[163,3456,3457,3460],{},[167,3458,3459],{},"Toast notifications"," — these are visually obvious but completely invisible to screen readers without a live region backing them.",[3462,3463,3464],"blockquote",{},[163,3465,3466,3469,3470,3472],{},[167,3467,3468],{},"Don't overuse this."," Most dynamic UI in Vue is better served by moving focus to the new content or using semantic HTML. ",[234,3471,3246],{}," is for the cases where there's no element to focus — a background process finishing, a count changing while the user types elsewhere. Reach for focus management first.",[179,3474,3476],{"id":3475},"the-announcer-pattern","The Announcer Pattern",[163,3478,3479,3480,3482],{},"Scattering ",[234,3481,3246],{}," regions throughout the app is messy. The announcer pattern centralizes this: one hidden live region in the DOM, one composable to control it from anywhere.",[163,3484,3485,3486,3488],{},"The region component renders a visually hidden ",[234,3487,3246],{}," element. The composable controls it:",[306,3490,3492],{"className":308,"code":3491,"language":310,"meta":311,"style":311},"const message = shallowRef('')\nconst politeness = shallowRef\u003CPoliteness>('polite')\n\nexport function useAnnouncer() {\n  function set(msg: string, level: Politeness = 'polite') {\n    message.value = ''\n    nextTick(() => {\n      politeness.value = level\n      message.value = msg\n    })\n  }\n\n  function polite(msg: string) { set(msg, 'polite') }\n  function assertive(msg: string) { set(msg, 'assertive') }\n\n  return { message, politeness, set, polite, assertive }\n}\n",[234,3493,3494,3513,3537,3541,3554,3588,3597,3608,3618,3628,3633,3637,3641,3670,3696,3700,3708],{"__ignoreMap":311},[315,3495,3496,3498,3501,3503,3506,3508,3511],{"class":317,"line":141},[315,3497,321],{"class":320},[315,3499,3500],{"class":324}," message",[315,3502,328],{"class":320},[315,3504,3505],{"class":331}," shallowRef",[315,3507,376],{"class":335},[315,3509,3510],{"class":453},"''",[315,3512,941],{"class":335},[315,3514,3515,3517,3520,3522,3524,3526,3529,3532,3535],{"class":317,"line":345},[315,3516,321],{"class":320},[315,3518,3519],{"class":324}," politeness",[315,3521,328],{"class":320},[315,3523,3505],{"class":331},[315,3525,723],{"class":335},[315,3527,3528],{"class":331},"Politeness",[315,3530,3531],{"class":335},">(",[315,3533,3534],{"class":453},"'polite'",[315,3536,941],{"class":335},[315,3538,3539],{"class":317,"line":126},[315,3540,365],{"emptyLinePlaceholder":138},[315,3542,3543,3546,3548,3551],{"class":317,"line":54},[315,3544,3545],{"class":320},"export",[315,3547,1652],{"class":320},[315,3549,3550],{"class":331}," useAnnouncer",[315,3552,3553],{"class":335},"() {\n",[315,3555,3556,3559,3562,3564,3567,3569,3571,3573,3576,3578,3581,3583,3586],{"class":317,"line":82},[315,3557,3558],{"class":320},"  function",[315,3560,3561],{"class":331}," set",[315,3563,376],{"class":335},[315,3565,3566],{"class":379},"msg",[315,3568,383],{"class":320},[315,3570,3379],{"class":324},[315,3572,1087],{"class":335},[315,3574,3575],{"class":379},"level",[315,3577,383],{"class":320},[315,3579,3580],{"class":331}," Politeness",[315,3582,328],{"class":320},[315,3584,3585],{"class":453}," 'polite'",[315,3587,389],{"class":335},[315,3589,3590,3593,3595],{"class":317,"line":36},[315,3591,3592],{"class":335},"    message.value ",[315,3594,465],{"class":320},[315,3596,3391],{"class":453},[315,3598,3599,3602,3604,3606],{"class":317,"line":58},[315,3600,3601],{"class":331},"    nextTick",[315,3603,960],{"class":335},[315,3605,3401],{"class":320},[315,3607,3404],{"class":335},[315,3609,3610,3613,3615],{"class":317,"line":439},[315,3611,3612],{"class":335},"      politeness.value ",[315,3614,465],{"class":320},[315,3616,3617],{"class":335}," level\n",[315,3619,3620,3623,3625],{"class":317,"line":144},[315,3621,3622],{"class":335},"      message.value ",[315,3624,465],{"class":320},[315,3626,3627],{"class":335}," msg\n",[315,3629,3630],{"class":317,"line":14},[315,3631,3632],{"class":335},"    })\n",[315,3634,3635],{"class":317,"line":488},[315,3636,647],{"class":335},[315,3638,3639],{"class":317,"line":42},[315,3640,365],{"emptyLinePlaceholder":138},[315,3642,3643,3645,3648,3650,3652,3654,3656,3659,3662,3665,3667],{"class":317,"line":503},[315,3644,3558],{"class":320},[315,3646,3647],{"class":331}," polite",[315,3649,376],{"class":335},[315,3651,3566],{"class":379},[315,3653,383],{"class":320},[315,3655,3379],{"class":324},[315,3657,3658],{"class":335},") { ",[315,3660,3661],{"class":331},"set",[315,3663,3664],{"class":335},"(msg, ",[315,3666,3534],{"class":453},[315,3668,3669],{"class":335},") }\n",[315,3671,3672,3674,3677,3679,3681,3683,3685,3687,3689,3691,3694],{"class":317,"line":533},[315,3673,3558],{"class":320},[315,3675,3676],{"class":331}," assertive",[315,3678,376],{"class":335},[315,3680,3566],{"class":379},[315,3682,383],{"class":320},[315,3684,3379],{"class":324},[315,3686,3658],{"class":335},[315,3688,3661],{"class":331},[315,3690,3664],{"class":335},[315,3692,3693],{"class":453},"'assertive'",[315,3695,3669],{"class":335},[315,3697,3698],{"class":317,"line":538},[315,3699,365],{"emptyLinePlaceholder":138},[315,3701,3702,3705],{"class":317,"line":548},[315,3703,3704],{"class":320},"  return",[315,3706,3707],{"class":335}," { message, politeness, set, polite, assertive }\n",[315,3709,3710],{"class":317,"line":558},[315,3711,711],{"class":335},[163,3713,3714,3715,3718],{},"Module-level refs make this a singleton — every call to ",[234,3716,3717],{},"useAnnouncer()"," shares the same live region.",[306,3720,3722],{"className":714,"code":3721,"language":716,"meta":311,"style":311},"\u003Cscript setup lang=\"ts\">\nconst { polite, assertive } = useAnnouncer()\n\nasync function submitForm() {\n  await $fetch('\u002Fapi\u002Fcontact', { method: 'POST', body: formData })\n    .then(() => polite('Message sent'))\n    .catch(() => assertive('Error: Failed to send message'))\n}\n\u003C\u002Fscript>\n",[234,3723,3724,3744,3766,3770,3782,3804,3826,3846,3850],{"__ignoreMap":311},[315,3725,3726,3728,3731,3734,3737,3739,3742],{"class":317,"line":141},[315,3727,723],{"class":335},[315,3729,3730],{"class":726},"script",[315,3732,3733],{"class":331}," setup",[315,3735,3736],{"class":331}," lang",[315,3738,465],{"class":335},[315,3740,3741],{"class":453},"\"ts\"",[315,3743,836],{"class":335},[315,3745,3746,3748,3751,3753,3755,3757,3760,3762,3764],{"class":317,"line":345},[315,3747,321],{"class":320},[315,3749,3750],{"class":335}," { ",[315,3752,3327],{"class":324},[315,3754,1087],{"class":335},[315,3756,3335],{"class":324},[315,3758,3759],{"class":335}," } ",[315,3761,465],{"class":320},[315,3763,3550],{"class":331},[315,3765,624],{"class":335},[315,3767,3768],{"class":317,"line":126},[315,3769,365],{"emptyLinePlaceholder":138},[315,3771,3772,3775,3777,3780],{"class":317,"line":54},[315,3773,3774],{"class":320},"async",[315,3776,1652],{"class":320},[315,3778,3779],{"class":331}," submitForm",[315,3781,3553],{"class":335},[315,3783,3784,3787,3790,3792,3795,3798,3801],{"class":317,"line":82},[315,3785,3786],{"class":320},"  await",[315,3788,3789],{"class":331}," $fetch",[315,3791,376],{"class":335},[315,3793,3794],{"class":453},"'\u002Fapi\u002Fcontact'",[315,3796,3797],{"class":335},", { method: ",[315,3799,3800],{"class":453},"'POST'",[315,3802,3803],{"class":335},", body: formData })\n",[315,3805,3806,3809,3812,3814,3816,3818,3820,3823],{"class":317,"line":36},[315,3807,3808],{"class":335},"    .",[315,3810,3811],{"class":331},"then",[315,3813,960],{"class":335},[315,3815,3401],{"class":320},[315,3817,3647],{"class":331},[315,3819,376],{"class":335},[315,3821,3822],{"class":453},"'Message sent'",[315,3824,3825],{"class":335},"))\n",[315,3827,3828,3830,3833,3835,3837,3839,3841,3844],{"class":317,"line":58},[315,3829,3808],{"class":335},[315,3831,3832],{"class":331},"catch",[315,3834,960],{"class":335},[315,3836,3401],{"class":320},[315,3838,3676],{"class":331},[315,3840,376],{"class":335},[315,3842,3843],{"class":453},"'Error: Failed to send message'",[315,3845,3825],{"class":335},[315,3847,3848],{"class":317,"line":439},[315,3849,711],{"class":335},[315,3851,3852,3854,3856],{"class":317,"line":144},[315,3853,846],{"class":335},[315,3855,3730],{"class":726},[315,3857,836],{"class":335},[179,3859,3861],{"id":3860},"when-not-to-use-this","When Not to Use This",[163,3863,3864],{},"The announcer is a tool of last resort. Prefer native semantics and focus management first:",[163,3866,3867,3870,3871,3874],{},[167,3868,3869],{},"Moving focus is often enough."," After navigating to a new page, focusing the ",[234,3872,3873],{},"\u003Ch1>"," announces the heading text. After deleting an item from a list, focusing the next item tells the user where they are. Focus is the primary channel screen readers use — an announcement is the fallback.",[163,3876,3877,3880,3881,3883,3884,3887,3888,3891],{},[167,3878,3879],{},"Native elements already announce."," An ",[234,3882,864],{}," with a visible ",[234,3885,3886],{},"\u003Clabel>"," doesn't need an announcer to tell the user what field they're in. A ",[234,3889,3890],{},"\u003Cbutton>"," announces its text content on focus. Don't replicate what the platform gives you for free.",[163,3893,3894,3897],{},[167,3895,3896],{},"Use the announcer when there's no focus target."," A background save that finishes. A timer that expires. A filter that reduces a list from 50 to 3 items while focus stays in the search input. These have no natural element to focus — that's when the announcer earns its place.",[163,3899,3900,3901,2404],{},"For composite widgets where focus management handles navigation directly, see ",[1506,3902,153],{"href":1571},[179,3904,1500],{"id":1499},[190,3906,3907,3914,3921,3937],{},[193,3908,3909],{},[1506,3910,3913],{"href":3911,"rel":3912},"https:\u002F\u002Fwww.w3.org\u002FWAI\u002FWCAG22\u002FTechniques\u002Faria\u002FARIA19",[1510],"ARIA19: Using aria-live regions to identify errors",[193,3915,3916],{},[1506,3917,3920],{"href":3918,"rel":3919},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAccessibility\u002FARIA\u002FGuides\u002FLive_regions",[1510],"MDN: ARIA live regions",[193,3922,3923,1429,3927,1429,3933],{},[1506,3924,22],{"href":3925,"rel":3926},"https:\u002F\u002Fnuxt.com\u002Fdocs\u002Fapi\u002Fcomposables\u002Fuse-announcer",[1510],[1506,3928,3930],{"href":3925,"rel":3929},[1510],[234,3931,3932],{},"useAnnouncer",[1506,3934,3936],{"href":3925,"rel":3935},[1510],"composable",[193,3938,3939],{},[1506,3940,3943],{"href":3941,"rel":3942},"https:\u002F\u002Fgithub.com\u002Fnuxt\u002Fnuxt\u002Fpull\u002F34318",[1510],"PR #34318 — feat(nuxt): add useAnnouncer",[1547,3945,3946],{},"html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sxg3X, html code.shiki .sxg3X{--shiki-default:#85E89D;--shiki-dark:#85E89D}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .s-3mD, html code.shiki .s-3mD{--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}",{"title":311,"searchDepth":345,"depth":345,"links":3948},[3949,3950,3953,3954,3955,3956],{"id":3250,"depth":345,"text":3251},{"id":3263,"depth":345,"text":3264,"children":3951},[3952],{"id":3353,"depth":126,"text":3354},{"id":3429,"depth":345,"text":3430},{"id":3475,"depth":345,"text":3476},{"id":3860,"depth":345,"text":3861},{"id":1499,"depth":345,"text":1500},"2026-05-15","How to make dynamic content changes visible to screen readers using aria-live regions, and how to wrap it into a reusable composable.",{},{"title":1544,"description":3958},[16,56,1574],"blog\u002Fuse-announcer-nuxt","ws8av4j97wRPwOsbpcCqba7WCvV1RkzDK8C0DquaNtw",[3965,3974,3983,3991],{"id":3966,"date":3967,"extension":10,"major":138,"meta":3968,"pr":3969,"project":3970,"stem":3971,"url":3972,"__hash__":3973},"contributions\u002Fcontributions\u002Flaravel-scout-908.yml","2025-02-11T15:12:45Z",{},"Fix: [Typesense] Add custom index support","laravel\u002Fscout","contributions\u002Flaravel-scout-908","https:\u002F\u002Fgithub.com\u002Flaravel\u002Fscout\u002Fpull\u002F908","NB0KM_Pi48_vwoiWLOOB2ool3TFuZrhkbAB8_o9Qwz4",{"id":3975,"date":3976,"extension":10,"major":138,"meta":3977,"pr":3978,"project":3979,"stem":3980,"url":3981,"__hash__":3982},"contributions\u002Fcontributions\u002Fnuxt-hints-322.yml","2026-04-25T21:12:38Z",{},"fix: respect `app.baseURL` in devtools iframe src","nuxt\u002Fhints","contributions\u002Fnuxt-hints-322","https:\u002F\u002Fgithub.com\u002Fnuxt\u002Fhints\u002Fpull\u002F322","160iggPzUtyS9E3sEh94if95YXYZSlKO8DAcH9YyoP0",{"id":3984,"date":3985,"extension":10,"major":138,"meta":3986,"pr":3987,"project":3988,"stem":3989,"url":3941,"__hash__":3990},"contributions\u002Fcontributions\u002Fnuxt-nuxt-34318.yml","2026-02-25T09:33:31Z",{},"feat(nuxt): add `useAnnouncer` composable and `\u003CNuxtAnnouncer>` component","nuxt\u002Fnuxt","contributions\u002Fnuxt-nuxt-34318","oyFFqkMw8Mf3NZvue9lxulqp1UPCbGR0Ez0rHDr2FNg",{"id":3992,"date":3993,"extension":10,"major":138,"meta":3994,"pr":3995,"project":3996,"stem":3997,"url":3998,"__hash__":3999},"contributions\u002Fcontributions\u002Fspatie-holidays-280.yml","2025-10-14T09:39:06Z",{},"fix: updated InvalidYear exception","spatie\u002Fholidays","contributions\u002Fspatie-holidays-280","https:\u002F\u002Fgithub.com\u002Fspatie\u002Fholidays\u002Fpull\u002F280","O_QLJBxL86W8a67wLkUzfT5fN1FYxIau7oQ_bTyuEzU",1780311892313]