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