Repository: scalar/scalar
Pull request #5004: fix(api-client): improve api client request and response area a11y
Author: login: hwkr
Created: 2025-03-06T21:51:46Z | Merged: 2025-03-08T20:48:43Z

- Focus moves onto the send button after selecting a request in the sidebar
- Request / response section labels use heading tags (`h2`)
- Big update to the section filter logic to make them use an aria tab pattern
- Make the response body focusable and scrollable
- Moved focus into the server dropdown (popover) on open
- Improved the screen reader label for the send request button

**Checklist**

I’ve gone through the following:

- [x] I’ve added an explanation _why_ this change is needed.
- [x] I’ve added a changeset (`pnpm changeset`).
- [ ] I’ve added tests for the regression or new feature.
- [x] I’ve updated the documentation.

<!-- See the [contributing guide](https://github.com/scalar/scalar/blob/main/CONTRIBUTING.md) for more information. -->



Diff:
diff --git a/.changeset/clever-zoos-flash.md b/.changeset/clever-zoos-flash.md
new file mode 100644
index 00000000000..51fdadd2980
--- /dev/null
+++ b/.changeset/clever-zoos-flash.md
@@ -0,0 +1,6 @@
+---
+'@scalar/api-client': patch
+'@scalar/components': patch
+---
+
+fix(api-client): improve api client request and response area a11y
diff --git a/packages/api-client/src/components/AddressBar/AddressBar.vue b/packages/api-client/src/components/AddressBar/AddressBar.vue
index 4117246ba63..a73bc22980b 100644
--- a/packages/api-client/src/components/AddressBar/AddressBar.vue
+++ b/packages/api-client/src/components/AddressBar/AddressBar.vue
@@ -41,6 +41,7 @@ const { requestMutators, events } = useWorkspace()
 const { layout } = useLayout()
 
 const addressBarRef = ref<typeof CodeInput | null>(null)
+const sendButtonRef = ref<typeof ScalarButton | null>(null)
 
 /** update the instance path parameters on change */
 const onUrlChange = (newPath: string) => {
@@ -106,6 +107,13 @@ events.requestStatus.on((status) => {
   if (status === 'abort') abortLoading()
 })
 
+/** Focus the address bar (or the send button if in modal layout) */
+events.focusAddressBar.on(() => {
+  console.log('focusAddressBar', sendButtonRef.value, addressBarRef.value)
+  if (layout === 'modal') sendButtonRef.value?.$el?.focus()
+  else addressBarRef.value?.focus()
+})
+
 function updateRequestMethod(method: RequestMethod) {
   requestMutators.edit(operation.uid, 'method', method)
 }
@@ -201,6 +209,7 @@ function updateRequestPath(url: string) {
           :operation="operation"
           :target="id" />
         <ScalarButton
+          ref="sendButtonRef"
           class="z-context-plus relative h-auto shrink-0 overflow-hidden py-1 pl-2 pr-2.5 font-bold"
           :disabled="isRequesting"
           @click="handleExecuteRequest">
@@ -213,7 +222,10 @@ function updateRequestPath(url: string) {
               size="xs" />
             <span class="text-xxs hidden lg:flex">Send</span>
           </span>
-          <span class="sr-only"> Send Request </span>
+          <span class="sr-only">
+            Send {{ operation.method }} request to {{ server?.url ?? ''
+            }}{{ operation.path }}
+          </span>
         </ScalarButton>
       </div>
     </div>
diff --git a/packages/api-client/src/components/IconSelector.vue b/packages/api-client/src/components/IconSelector.vue
index 0d9ac4c5778..1a14263f27e 100644
--- a/packages/api-client/src/components/IconSelector.vue
+++ b/packages/api-client/src/components/IconSelector.vue
@@ -25,6 +25,7 @@ const value = computed<string>({
 <template>
   <ScalarPopover
     class="bg-b-2 rounded"
+    focus
     :placement="placement ?? 'bottom'">
     <slot />
     <template #popover="{ close }">
diff --git a/packages/api-client/src/components/ContextBar.vue b/packages/api-client/src/components/SectionFilter.vue
similarity index 66%
rename from packages/api-client/src/components/ContextBar.vue
rename to packages/api-client/src/components/SectionFilter.vue
index 6f1aecb9c3e..1f96342dced 100644
--- a/packages/api-client/src/components/ContextBar.vue
+++ b/packages/api-client/src/components/SectionFilter.vue
@@ -1,42 +1,58 @@
-<script setup lang="ts">
+<script setup lang="ts" generic="T extends string">
 import { ScalarIcon } from '@scalar/components'
-import { computed, useId } from 'vue'
+import { nextTick, ref } from 'vue'
 
-const props = defineProps<{
-  sections: string[]
-  activeSection: string
-}>()
+import SectionFilterButton from '@/components/SectionFilterButton.vue'
 
-const emit = defineEmits<{
-  (e: 'setActiveSection', value: string): void
+const { filters = [], filterIds } = defineProps<{
+  filters?: T[]
+  filterIds?: Record<T, string>
 }>()
 
-const name = useId()
+const model = defineModel<T>()
+
+const tablist = ref<HTMLDivElement>()
+
+/** Keyboard navigation */
+const navigateSection = (direction: 'next' | 'prev') => {
+  const offset = direction === 'prev' ? -1 : 1
+  const index = model.value ? filters.indexOf(model.value) : 0
+  const length = filters.length
 
-const model = computed<string>({
-  get: () => props.activeSection,
-  set: (value) => emit('setActiveSection', value),
-})
+  // Ensures we loop around the array by using the remainder
+  const newIndex = (index + offset + length) % length
+  model.value = filters[newIndex]
+
+  // Focus the selected button
+  nextTick(() => {
+    if (tablist.value) {
+      const selectedButton = tablist.value.querySelector(
+        `button[aria-selected="true"]`,
+      ) satisfies HTMLButtonElement | null
+      if (selectedButton) selectedButton.focus()
+    }
+  })
+}
 </script>
 <template>
-  <fieldset
-    class="filter-hover context-bar-group ml-auto hidden lg:flex lg:w-[120px]">
-    <legend class="sr-only">Filter Sections</legend>
+  <div
+    ref="tablist"
+    class="filter-hover context-bar-group ml-auto hidden lg:flex"
+    role="tablist"
+    @keydown.left="navigateSection('prev')"
+    @keydown.right="navigateSection('next')">
     <div
       class="request-section-content request-section-content-filter fade-request-section-content text-c-3 pointer-events-auto relative hidden w-full justify-end gap-[1.5px] rounded py-2 text-xs xl:flex">
-      <label
-        v-for="section in sections"
-        :key="section"
-        class="filter-hover-item hover:bg-b-2 flex w-fit cursor-pointer items-center whitespace-nowrap rounded p-1 px-2 text-center font-medium has-[:focus-visible]:outline"
-        :class="[model === section ? 'text-c-1 pointer-events-none' : '']">
-        {{ section }}
-        <input
-          v-model="model"
-          class="sr-only"
-          :name="name"
-          type="radio"
-          :value="section" />
-      </label>
+      <SectionFilterButton
+        v-for="filter in filters"
+        :key="filter"
+        class="filter-hover-item"
+        :controls="filterIds?.[filter]"
+        role="tab"
+        :selected="model === filter"
+        @click="model = filter">
+        {{ filter }}
+      </SectionFilterButton>
       <div
         class="context-bar-group-hover:text-c-1 absolute -right-[30px] top-1/2 flex -translate-y-1/2 items-center">
         <span class="context-bar-group-hover:hidden mr-1.5">{{ model }}</span>
@@ -46,7 +62,7 @@ const model = computed<string>({
           thickness="2" />
       </div>
     </div>
-  </fieldset>
+  </div>
 </template>
 <style scoped>
 .fade-request-section-content {
diff --git a/packages/api-client/src/components/SectionFilterButton.vue b/packages/api-client/src/components/SectionFilterButton.vue
new file mode 100644
index 00000000000..69b11e63dbf
--- /dev/null
+++ b/packages/api-client/src/components/SectionFilterButton.vue
@@ -0,0 +1,18 @@
+<script setup lang="ts">
+const { selected, controls } = defineProps<{
+  selected?: boolean
+  controls?: string | undefined
+}>()
+</script>
+<template>
+  <button
+    :aria-controls="controls ?? ''"
+    :aria-selected="!!selected"
+    class="hover:bg-b-2 flex w-fit cursor-pointer items-center whitespace-nowrap rounded p-1 px-2 text-center font-medium has-[:focus-visible]:outline"
+    :class="{ 'text-c-1 pointer-events-none': selected }"
+    role="tab"
+    :tabindex="selected ? 0 : -1"
+    type="button">
+    <slot />
+  </button>
+</template>
diff --git a/packages/api-client/src/components/Server/ServerDropdown.vue b/packages/api-client/src/components/Server/ServerDropdown.vue
index ab15d3b1cc6..3e517ef5ea3 100644
--- a/packages/api-client/src/components/Server/ServerDropdown.vue
+++ b/packages/api-client/src/components/Server/ServerDropdown.vue
@@ -93,6 +93,7 @@ const updateServerVariable = (key: string, value: string) => {
 <template>
   <ScalarPopover
     class="max-h-[inherit] p-0 text-sm"
+    focus
     :offset="0"
     placement="bottom-start"
     resize
diff --git a/packages/api-client/src/components/ViewLayout/ViewLayoutCollapse.vue b/packages/api-client/src/components/ViewLayout/ViewLayoutCollapse.vue
index 23b9e6327b6..d43ce9d2e17 100644
--- a/packages/api-client/src/components/ViewLayout/ViewLayoutCollapse.vue
+++ b/packages/api-client/src/components/ViewLayout/ViewLayoutCollapse.vue
@@ -1,6 +1,7 @@
 <script setup lang="ts">
 import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
 import { ScalarIcon } from '@scalar/components'
+import { useId } from 'vue'
 
 const {
   defaultOpen = true,
@@ -11,6 +12,8 @@ const {
   itemCount?: number
   layout?: 'client' | 'reference'
 }>()
+
+const id = useId()
 </script>
 <template>
   <Disclosure
@@ -18,46 +21,60 @@ const {
     as="div"
     class="group/collapse focus-within:text-c-1 text-c-2 request-item"
     :defaultOpen="defaultOpen"
-    :static="layout === 'reference'">
-    <div class="bg-b-2 flex items-center">
-      <DisclosureButton
-        :class="[
-          'hover:text-c-1 group box-content flex max-h-8 flex-1 items-center gap-2.5 overflow-hidden px-1 py-1.5 text-sm font-medium outline-none md:px-1.5 xl:pl-2 xl:pr-0.5',
-          { '!pl-3': layout === 'reference' },
-          { 'group-last/collapse:border-b': layout === 'client' },
-        ]"
-        :disabled="layout === 'reference'">
-        <ScalarIcon
-          v-if="layout !== 'reference'"
+    :static="layout === 'reference' ? true : undefined">
+    <section
+      :aria-labelledby="id"
+      class="contents">
+      <div class="bg-b-2 flex items-center">
+        <DisclosureButton
           :class="[
-            'text-c-3 group-hover:text-c-1 ui-open:rotate-90 ui-not-open:rotate-0 rounded-px outline-offset-2 group-focus-visible:outline',
+            'hover:text-c-1 group box-content flex max-h-8 flex-1 items-center gap-2.5 overflow-hidden px-1 py-1.5 text-sm font-medium outline-none md:px-1.5 xl:pl-2 xl:pr-0.5',
+            { '!pl-3': layout === 'reference' },
+            { 'group-last/collapse:border-b': layout === 'client' },
           ]"
-          icon="ChevronRight"
-          size="md" />
-        <div class="text-c-1 flex flex-1 items-center gap-1.5">
+          :disabled="layout === 'reference'">
+          <ScalarIcon
+            v-if="layout !== 'reference'"
+            :class="[
+              'text-c-3 group-hover:text-c-1 ui-open:rotate-90 ui-not-open:rotate-0 rounded-px outline-offset-2 group-focus-visible:outline',
+            ]"
+            icon="ChevronRight"
+            size="md" />
+          <h2 class="text-c-1 flex flex-1 items-center gap-1.5">
+            <span
+              :id="id"
+              class="contents">
+              <slot
+                name="title"
+                :open="open" />
+              <span
+                v-if="!open"
+                class="sr-only">
+                (Collapsed)
+              </span>
+            </span>
+            <span
+              v-if="!open && itemCount"
+              class="bg-b-2 text-c-2 text-3xs inline-flex h-4 w-4 items-center justify-center rounded-full border font-semibold">
+              {{ itemCount }}
+              <span class="sr-only">Item{{ itemCount === 1 ? '' : 's' }}</span>
+            </span>
+          </h2>
+        </DisclosureButton>
+        <div
+          v-if="$slots.actions"
+          class="ui-not-open:invisible flex items-center gap-2 pr-1.5">
           <slot
-            name="title"
+            name="actions"
             :open="open" />
-          <span
-            v-if="!open && itemCount"
-            class="bg-b-2 text-c-2 text-3xs inline-flex h-4 w-4 items-center justify-center rounded-full border font-semibold">
-            {{ itemCount }}
-          </span>
         </div>
-      </DisclosureButton>
-      <div
-        v-if="$slots.actions"
-        class="ui-not-open:invisible flex items-center gap-2 pr-1.5">
-        <slot
-          name="actions"
-          :open="open" />
       </div>
-    </div>
 
-    <DisclosurePanel
-      v-bind="$attrs"
-      class="diclosure-panel h-full max-h-fit rounded-b">
-      <slot :open="open" />
-    </DisclosurePanel>
+      <DisclosurePanel
+        v-bind="$attrs"
+        class="diclosure-panel h-full max-h-fit rounded-b">
+        <slot :open="open" />
+      </DisclosurePanel>
+    </section>
   </Disclosure>
 </template>
diff --git a/packages/api-client/src/store/events.ts b/packages/api-client/src/store/events.ts
index afb1d6f4a1b..1de1ba2fd57 100644
--- a/packages/api-client/src/store/events.ts
+++ b/packages/api-client/src/store/events.ts
@@ -11,6 +11,10 @@ export function createStoreEvents() {
      * OR the keyboard shortcut
      */
     executeRequest: createEventBus<{ requestUid?: string }>(),
+    /**
+     * Event bus to focus the address bar or send button
+     */
+    focusAddressBar: createEventBus(),
     /**
      * Event bus to cancel requests, usually triggered by response loading overlay
      */
diff --git a/packages/api-client/src/views/Request/RequestSection/RequestSection.vue b/packages/api-client/src/views/Request/RequestSection/RequestSection.vue
index c623a050656..4bf5e18ced4 100644
--- a/packages/api-client/src/views/Request/RequestSection/RequestSection.vue
+++ b/packages/api-client/src/views/Request/RequestSection/RequestSection.vue
@@ -10,9 +10,9 @@ import type {
   Server,
 } from '@scalar/oas-utils/entities/spec'
 import { canMethodHaveBody, isDefined, REGEX } from '@scalar/oas-utils/helpers'
-import { computed, ref, watch } from 'vue'
+import { computed, ref, useId, watch } from 'vue'
 
-import ContextBar from '@/components/ContextBar.vue'
+import SectionFilter from '@/components/SectionFilter.vue'
 import ViewLayoutSection from '@/components/ViewLayout/ViewLayoutSection.vue'
 import { useLayout } from '@/hooks'
 import { matchesDomain } from '@/libs/send-request/set-request-cookies'
@@ -46,19 +46,22 @@ const {
   workspace: Workspace
 }>()
 
+const requestSections = [
+  'Auth',
+  'Variables',
+  'Cookies',
+  'Headers',
+  'Query',
+  'Body',
+] as const
+
+type Filter = 'All' | (typeof requestSections)[number]
+
 const { requestMutators, cookies, securitySchemes } = useWorkspace()
 const { layout } = useLayout()
 
-const sections = computed(() => {
-  const allSections = new Set([
-    'All',
-    'Query',
-    'Auth',
-    'Variables',
-    'Cookies',
-    'Headers',
-    'Body',
-  ])
+const filters = computed<Filter[]>(() => {
+  const allSections = new Set<Filter>(['All', ...requestSections])
 
   if (!example.parameters.path.length) allSections.delete('Variables')
   if (!canMethodHaveBody(operation.method ?? 'get')) allSections.delete('Body')
@@ -67,24 +70,30 @@ const sections = computed(() => {
   return [...allSections]
 })
 
+/** A list of section ids */
+const filterIds = computed(
+  () =>
+    Object.fromEntries(
+      filters.value.map((section) => [section, useId()]),
+    ) as Record<Filter, string>,
+)
+
 // If security = [] or [{}] just hide it on readOnly mode
 const isAuthHidden = computed(
   () => layout === 'modal' && operation.security?.length === 0,
 )
 
-type ActiveSections = (typeof sections.value)[number]
-
-const activeSection = ref<ActiveSections>('All')
+const selectedFilter = ref<Filter>('All')
 
 watch(
   () => operation,
   (newOperation) => {
     if (
-      activeSection.value === 'Body' &&
+      selectedFilter.value === 'Body' &&
       newOperation &&
       !canMethodHaveBody(newOperation.method)
     ) {
-      activeSection.value = 'All'
+      selectedFilter.value = 'All'
     }
   },
 )
@@ -149,13 +158,15 @@ const handleRequestNamePlaceholder = () => {
           {{ operation.summary }}
         </span>
       </div>
-      <ContextBar
-        :activeSection="activeSection"
-        :sections="sections"
-        @setActiveSection="activeSection = $event" />
+      <SectionFilter
+        v-model="selectedFilter"
+        :filterIds="filterIds"
+        :filters="filters" />
     </template>
     <div
-      class="request-section-content custom-scroll relative flex flex-1 flex-col divide-y">
+      :id="filterIds.All"
+      class="request-section-content custom-scroll relative flex flex-1 flex-col divide-y"
+      :role="selectedFilter === 'All' ? 'tabpanel' : 'none'">
       <RequestAuth
         v-if="
           collection &&
@@ -163,32 +174,38 @@ const handleRequestNamePlaceholder = () => {
           (layout !== 'modal' || Object.keys(securitySchemes ?? {}).length)
         "
         v-show="
-          !isAuthHidden && (activeSection === 'All' || activeSection === 'Auth')
+          !isAuthHidden &&
+          (selectedFilter === 'All' || selectedFilter === 'Auth')
         "
+        :id="filterIds.Auth"
         :collection="collection"
         :envVariables="envVariables"
         :environment="environment"
         layout="client"
         :operation="operation"
+        :role="selectedFilter === 'All' ? 'none' : 'tabpanel'"
         :selectedSecuritySchemeUids="selectedSecuritySchemeUids"
         :server="server"
         title="Authentication"
         :workspace="workspace" />
       <RequestPathParams
         v-show="
-          (activeSection === 'All' || activeSection === 'Variables') &&
+          (selectedFilter === 'All' || selectedFilter === 'Variables') &&
           example.parameters.path.length
         "
+        :id="filterIds.Variables"
         :envVariables="envVariables"
         :environment="environment"
         :example="example"
+        :invalidParams="invalidParams"
         :operation="operation"
         paramKey="path"
+        :role="selectedFilter === 'All' ? 'none' : 'tabpanel'"
         title="Variables"
-        :workspace="workspace"
-        :invalidParams="invalidParams" />
+        :workspace="workspace" />
       <RequestParams
-        v-show="activeSection === 'All' || activeSection === 'Cookies'"
+        v-show="selectedFilter === 'All' || selectedFilter === 'Cookies'"
+        :id="filterIds.Cookies"
         :envVariables="envVariables"
         :environment="environment"
         :example="example"
@@ -196,39 +213,46 @@ const handleRequestNamePlaceholder = () => {
         :operation="operation"
         paramKey="cookies"
         :readOnlyEntries="activeWorkspaceCookies"
+        :role="selectedFilter === 'All' ? 'none' : 'tabpanel'"
         title="Cookies"
         :workspace="workspace"
         workspaceParamKey="cookies" />
       <RequestParams
-        v-show="activeSection === 'All' || activeSection === 'Headers'"
+        v-show="selectedFilter === 'All' || selectedFilter === 'Headers'"
+        :id="filterIds.Headers"
         :envVariables="envVariables"
         :environment="environment"
         :example="example"
         :invalidParams="invalidParams"
         :operation="operation"
         paramKey="headers"
+        :role="selectedFilter === 'All' ? 'none' : 'tabpanel'"
         title="Headers"
         :workspace="workspace" />
       <RequestParams
-        v-show="activeSection === 'All' || activeSection === 'Query'"
+        v-show="selectedFilter === 'All' || selectedFilter === 'Query'"
+        :id="filterIds.Query"
         :envVariables="envVariables"
         :environment="environment"
         :example="example"
         :invalidParams="invalidParams"
         :operation="operation"
         paramKey="query"
+        :role="selectedFilter === 'All' ? 'none' : 'tabpanel'"
         title="Query Parameters"
         :workspace="workspace" />
       <RequestBody
         v-show="
           operation.method &&
-          (activeSection === 'All' || activeSection === 'Body') &&
+          (selectedFilter === 'All' || selectedFilter === 'Body') &&
           canMethodHaveBody(operation.method)
         "
+        :id="filterIds.Body"
         :envVariables="envVariables"
         :environment="environment"
         :example="example"
         :operation="operation"
+        :role="selectedFilter === 'All' ? 'none' : 'tabpanel'"
         title="Body"
         :workspace="workspace" />
 
diff --git a/packages/api-client/src/views/Request/RequestSidebarItem.vue b/packages/api-client/src/views/Request/RequestSidebarItem.vue
index b61cdb0e266..fb0e5df6c46 100644
--- a/packages/api-client/src/views/Request/RequestSidebarItem.vue
+++ b/packages/api-client/src/views/Request/RequestSidebarItem.vue
@@ -13,7 +13,7 @@ import {
 } from '@scalar/draggable'
 import type { Collection, Request } from '@scalar/oas-utils/entities/spec'
 import { shouldIgnoreEntity } from '@scalar/oas-utils/helpers'
-import { computed, ref } from 'vue'
+import { computed, nextTick, ref } from 'vue'
 import { RouterLink, useRouter } from 'vue-router'
 
 import { HttpMethod } from '@/components/HttpMethod'
@@ -279,6 +279,8 @@ const handleNavigation = (event: KeyboardEvent, _item: SidebarItem) => {
 
     if (isModifierPressed) emit('newTab', _item.title || '', _item.entity.uid)
     else if (_item.to) router.push(_item.to)
+
+    nextTick(() => events.focusAddressBar.emit())
   }
 }
 
diff --git a/packages/api-client/src/views/Request/ResponseSection/ResponseBody.vue b/packages/api-client/src/views/Request/ResponseSection/ResponseBody.vue
index 652c309b8c7..3ac9743fd3a 100644
--- a/packages/api-client/src/views/Request/ResponseSection/ResponseBody.vue
+++ b/packages/api-client/src/views/Request/ResponseSection/ResponseBody.vue
@@ -35,7 +35,7 @@ const { mimeType, attachmentFilename, dataUrl } = useResponseBody({
 const mediaConfig = computed(() => mediaTypes[mimeType.value.essence])
 </script>
 <template>
-  <ViewLayoutCollapse class="max-h-content overflow-x-auto">
+  <ViewLayoutCollapse class="max-h-content overflow-y-hidden">
     <template #title>{{ title }}</template>
     <template
       v-if="data && dataUrl"
diff --git a/packages/api-client/src/views/Request/ResponseSection/ResponseBodyRaw.vue b/packages/api-client/src/views/Request/ResponseSection/ResponseBodyRaw.vue
index e21684ff9ed..c66edf9c5c2 100644
--- a/packages/api-client/src/views/Request/ResponseSection/ResponseBodyRaw.vue
+++ b/packages/api-client/src/views/Request/ResponseSection/ResponseBodyRaw.vue
@@ -28,7 +28,8 @@ const getCurrentContent = () => {
 }
 </script>
 <template>
-  <div class="body-raw relative overflow-auto">
+  <div
+    class="body-raw grid min-h-0 overflow-hidden p-px outline-none has-[:focus-visible]:outline">
     <!-- Copy button -->
     <div
       v-if="getCurrentContent()"
@@ -43,9 +44,12 @@ const getCurrentContent = () => {
           size="md" />
       </button>
     </div>
-
-    <!-- CodeMirror container -->
-    <div ref="codeMirrorRef" />
+    <div
+      class="body-raw-scroller custom-scroll relative overflow-x-auto overscroll-contain"
+      tabindex="0">
+      <!-- CodeMirror container -->
+      <div ref="codeMirrorRef" />
+    </div>
   </div>
 </template>
 <style scoped>
@@ -59,6 +63,11 @@ const getCurrentContent = () => {
   border-radius: var(--scalar-radius) 0 0 var(--scalar-radius);
 }
 
+.body-raw :deep(.cm-scroller) {
+  overflow: visible;
+  width: fit-content;
+}
+
 /* Copy Button Styles */
 .scalar-code-copy {
   align-items: flex-start;
diff --git a/packages/api-client/src/views/Request/ResponseSection/ResponseSection.vue b/packages/api-client/src/views/Request/ResponseSection/ResponseSection.vue
index 52c5a19669b..2ab6102d792 100644
--- a/packages/api-client/src/views/Request/ResponseSection/ResponseSection.vue
+++ b/packages/api-client/src/views/Request/ResponseSection/ResponseSection.vue
@@ -1,8 +1,8 @@
 <script setup lang="ts">
 import type { ResponseInstance } from '@scalar/oas-utils/entities/spec'
-import { computed, ref } from 'vue'
+import { computed, ref, useId } from 'vue'
 
-import ContextBar from '@/components/ContextBar.vue'
+import SectionFilter from '@/components/SectionFilter.vue'
 import ViewLayoutSection from '@/components/ViewLayout/ViewLayoutSection.vue'
 import ResponseBody from '@/views/Request/ResponseSection/ResponseBody.vue'
 import ResponseEmpty from '@/views/Request/ResponseSection/ResponseEmpty.vue'
@@ -55,9 +55,18 @@ const responseCookies = computed(
     }) ?? [],
 )
 
-const sections = ['All', 'Cookies', 'Headers', 'Body']
-type ActiveSections = (typeof sections)[number]
-const activeSection = ref<ActiveSections>('All')
+const responseSections = ['Cookies', 'Headers', 'Body'] as const
+type Filter = 'All' | (typeof responseSections)[number]
+const activeFilter = ref<Filter>('All')
+
+const filters = computed<Filter[]>(() => ['All', ...responseSections])
+
+const filterIds = computed(
+  () =>
+    Object.fromEntries(
+      filters.value.map((section) => [section, useId()]),
+    ) as Record<Filter, string>,
+)
 
 /** Threshold for virtualizing response bodies in bytes */
 const VIRTUALIZATION_THRESHOLD = 200_000
@@ -128,41 +137,51 @@ const shouldVirtualize = computed(() => {
             class="animate-response-children"
             :response="response" />
         </div>
-        <ContextBar
-          :activeSection="activeSection"
-          :sections="sections"
-          @setActiveSection="activeSection = $event" />
+        <SectionFilter
+          v-model="activeFilter"
+          :filterIds="filterIds"
+          :filters="filters" />
       </div>
     </template>
     <div
+      :id="filterIds.All"
       class="custom-scroll relative grid h-full justify-stretch divide-y"
       :class="{
         'content-start': response,
-      }">
+      }"
+      :role="activeFilter === 'All' && response ? 'tabpanel' : 'none'">
       <template v-if="!response">
         <ResponseEmpty :numWorkspaceRequests="numWorkspaceRequests" />
       </template>
       <template v-else>
         <ResponseCookies
-          v-if="activeSection === 'All' || activeSection === 'Cookies'"
-          :cookies="responseCookies" />
+          v-if="activeFilter === 'All' || activeFilter === 'Cookies'"
+          :id="filterIds.Cookies"
+          :cookies="responseCookies"
+          :role="activeFilter === 'All' ? 'none' : 'tabpanel'" />
         <ResponseHeaders
-          v-if="activeSection === 'All' || activeSection === 'Headers'"
-          :headers="responseHeaders" />
+          v-if="activeFilter === 'All' || activeFilter === 'Headers'"
+          :id="filterIds.Headers"
+          :headers="responseHeaders"
+          :role="activeFilter === 'All' ? 'none' : 'tabpanel'" />
 
-        <template v-if="activeSection === 'All' || activeSection === 'Body'">
+        <template v-if="activeFilter === 'All' || activeFilter === 'Body'">
           <!-- Virtualized Text for massive responses -->
           <ResponseBodyVirtual
             v-if="shouldVirtualize && typeof response?.data === 'string'"
+            :id="filterIds.Body"
             :content="response!.data"
             :data="response?.data"
-            :headers="responseHeaders" />
+            :headers="responseHeaders"
+            :role="activeFilter === 'All' ? 'none' : 'tabpanel'" />
 
           <ResponseBody
             v-else
+            :id="filterIds.Body"
             :active="true"
             :data="response?.data"
             :headers="responseHeaders"
+            :role="activeFilter === 'All' ? 'none' : 'tabpanel'"
             title="Body" />
         </template>
       </template>
diff --git a/packages/components/src/components/ScalarPopover/ScalarPopover.vue b/packages/components/src/components/ScalarPopover/ScalarPopover.vue
index 6b0e7ce0648..bc03e014aa1 100644
--- a/packages/components/src/components/ScalarPopover/ScalarPopover.vue
+++ b/packages/components/src/components/ScalarPopover/ScalarPopover.vue
@@ -9,7 +9,12 @@ import {
 } from '../ScalarFloating'
 import type { Slots } from './types'
 
-defineProps<ScalarFloatingOptions>()
+defineProps<
+  {
+    /** Automatically focus the popover panel when it opens */
+    focus?: boolean
+  } & ScalarFloatingOptions
+>()
 
 defineSlots<Slots>()
 
@@ -27,6 +32,7 @@ const { cx } = useBindCx()
       <template #floating="{ width, height }">
         <PopoverPanel
           v-slot="{ close }"
+          :focus="focus"
           :style="{ width, height }"
           v-bind="cx('relative flex flex-col p-0.75')">
           <slot
diff --git a/playwright/tests/testPlayButton.ts b/playwright/tests/testPlayButton.ts
index f8f22e52879..497979fe4af 100644
--- a/playwright/tests/testPlayButton.ts
+++ b/playwright/tests/testPlayButton.ts
@@ -15,7 +15,7 @@ export async function testPlayButton(page: Page) {
   await page.waitForSelector('.scalar-client')
 
   // URL
-  await expect(page.getByText('https://galaxy.scalar.com')).toBeVisible()
+  await expect(page.getByRole('button', { name: 'Server: https://galaxy.scalar.com' })).toBeVisible()
   // Path
-  await expect(page.getByText('/planets')).toBeVisible()
+  await expect(page.getByText('/planets', { exact: true })).toBeVisible()
 }


Reviews:
user: login: amritk
body: Code looks great! Leaving to marQ-a for testing
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-07T20:08:50Z
user: login: marclave
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-08T20:48:38Z

Review comments:
diff_hunk: @@ -106,6 +107,13 @@ events.requestStatus.on((status) => {
   if (status === 'abort') abortLoading()
 })
 
+/** Focus the address bar (or the send button if in modal layout) */
+events.focusAddressBar.on(() => {
+  console.log('focusAddressBar', sendButtonRef.value, addressBarRef.value)
path: packages/api-client/src/components/AddressBar/AddressBar.vue
user: login: amritk
body: ```suggestion
```
created_at: 2025-03-07T19:54:39Z
author_association: MEMBER
diff_hunk: @@ -1,42 +1,58 @@
-<script setup lang="ts">
+<script setup lang="ts" generic="T extends string">
 import { ScalarIcon } from '@scalar/components'
-import { computed, useId } from 'vue'
+import { nextTick, ref } from 'vue'
 
-const props = defineProps<{
-  sections: string[]
-  activeSection: string
-}>()
+import SectionFilterButton from '@/components/SectionFilterButton.vue'
 
-const emit = defineEmits<{
-  (e: 'setActiveSection', value: string): void
+const { filters = [], filterIds } = defineProps<{
+  filters?: T[]
+  filterIds?: Record<T, string>
 }>()
 
-const name = useId()
+const model = defineModel<T>()
+
+const tablist = ref<HTMLDivElement>()
+
+/** Keyboard navigation */
+const navigateSection = (direction: 'next' | 'prev') => {
+  const offset = direction === 'prev' ? -1 : 1
+  const index = model.value ? filters.indexOf(model.value) : 0
+  const length = filters.length
 
-const model = computed<string>({
-  get: () => props.activeSection,
-  set: (value) => emit('setActiveSection', value),
-})
+  // Ensures we loop around the array by using the remainder
+  const newIndex = (index + offset + length) % length
+  model.value = filters[newIndex]
+
+  // Focus the selected button
+  nextTick(() => {
path: packages/api-client/src/components/SectionFilter.vue
user: login: amritk
body: What was the need for nextTick here? I'm assuming the button already exists
created_at: 2025-03-07T19:58:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,18 @@
+<script setup lang="ts">
+const { selected, controls } = defineProps<{
+  selected?: boolean
+  controls?: string | undefined
+}>()
+</script>
+<template>
+  <button
+    :aria-controls="controls ?? ''"
+    :aria-selected="!!selected"
path: packages/api-client/src/components/SectionFilterButton.vue
user: login: amritk
body: ```suggestion
    :aria-selected="Boolean(selected)"
```
nitpick doesn't really matter
created_at: 2025-03-07T20:03:06Z
author_association: MEMBER
diff_hunk: @@ -55,9 +55,18 @@ const responseCookies = computed(
     }) ?? [],
 )
 
-const sections = ['All', 'Cookies', 'Headers', 'Body']
-type ActiveSections = (typeof sections)[number]
-const activeSection = ref<ActiveSections>('All')
+const responseSections = ['Cookies', 'Headers', 'Body'] as const
+type Filter = 'All' | (typeof responseSections)[number]
+const activeFilter = ref<Filter>('All')
path: packages/api-client/src/views/Request/ResponseSection/ResponseSection.vue
user: login: amritk
body: 👍🏾 
created_at: 2025-03-07T20:04:17Z
author_association: MEMBER

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-03-06T21:51:50Z
body: ###  🦋  Changeset detected

Latest commit: 998b21aaee8183fcd2dc611f0494fa1a34b37ce0

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 16 packages</summary>

  | Name                          | Type  |
| ----------------------------- | ----- |
| @scalar/api-client            | Patch |
| @scalar/components            | Patch |
| @scalar/nuxt                  | Patch |
| @scalar/api-client-react      | Patch |
| @scalar/api-reference-editor  | Patch |
| @scalar/api-reference         | Patch |
| @scalar/play-button           | Patch |
| scalar-app                    | Patch |
| @scalar/use-codemirror        | Patch |
| @scalar/aspnetcore            | Patch |
| @scalar/express-api-reference | Patch |
| @scalar/fastify-api-reference | Patch |
| @scalar/hono-api-reference    | Patch |
| @scalar/api-reference-react   | Patch |
| @scalar/cli                   | Patch |
| @scalar/docusaurus            | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/scalar/scalar/new/brynn/doc-2627-api-client-request-area-a11y?filename=.changeset/early-windows-serve.md&value=---%0A%22%40scalar%2Fapi-client%22%3A%20patch%0A%22%40scalar%2Fcomponents%22%3A%20patch%0A%22playwright%22%3A%20patch%0A---%0A%0Afix(api-client)%3A%20improve%20api%20client%20request%20and%20response%20area%20a11y%0A)


author_association: NONE
user: login: relativeci[bot]
created_at: 2025-03-06T21:59:27Z
body: ## [#8527](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb?utm_source=github&utm_campaign=pr-report&utm_content=title "Bundle Size — 2.39MiB (+0.1%).") Bundle Size — 2.39MiB (+0.1%).
998b21a(current) vs fb22645 **[main#8524](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8524-szFwcs3mrhBmzZaEOUB7?utm_source=github&utm_content=report-header&utm_campaign=pr-report "View baseline job report")**(baseline)

> [!WARNING]
> Bundle contains 6 duplicate packages – [View duplicate packages](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/packages?bp=%7B%22filters%22%3A%22duplicate-1%22%7D&utm_source=github&utm_campaign=pr-report&utm_content=insights)
<details>
<summary><strong>Bundle metrics</strong>&nbsp;&nbsp;<img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-blue.svg?sanitize=true" width="8" alt="Change" align="center"> <code>2 changes</code> <img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-red.svg?sanitize=true" width="8" alt="Regression" align="center"> <code>1 regression</code></summary>
<br>


|            |       Current<br />[#8527](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb?utm_source=github&utm_content=summary&utm_campaign=pr-report "View bundle analysis report") |      Baseline<br />[#8524](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8524-szFwcs3mrhBmzZaEOUB7?utm_source=github&utm_content=summary&utm_campaign=pr-report "View baseline bundle analysis report") |
|:--|--:|--:|
| <strong><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-red.svg?sanitize=true" width="8" alt="Regression" align="center">&nbsp;&nbsp;[Initial JS](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/assets?ba=%7B%22filters%22%3A%22et.entrypoint-0_et.initial-1_et.chunk-0_et.asset-0_ft.CSS-0_ft.JS-1_ft.IMG-0_ft.MEDIA-0_ft.FONT-0_ft.HTML-0_ft.OTHER-0%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View initial JS assets")</strong> | <strong>`2.39MiB`(`+0.1%`)</strong> | <strong>`2.39MiB`</strong> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-gray.svg?sanitize=true" width="8" alt="No change" align="center">&nbsp;&nbsp;[Initial CSS](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/assets?ba=%7B%22filters%22%3A%22et.entrypoint-0_et.initial-1_et.chunk-0_et.asset-0_ft.CSS-1_ft.JS-0_ft.IMG-0_ft.MEDIA-0_ft.FONT-0_ft.HTML-0_ft.OTHER-0%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View initial CSS assets")</span> | <span>`0B`</span> | <span>`0B`</span> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-gray.svg?sanitize=true" width="8" alt="No change" align="center">&nbsp;&nbsp;[Cache Invalidation](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/assets?ba=%7B%22filters%22%3A%22changed-1%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View changed assets")</span> | <span>`100%`</span> | <span>`100%`</span> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-gray.svg?sanitize=true" width="8" alt="No change" align="center">&nbsp;&nbsp;[Chunks](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/assets?ba=%7B%22filters%22%3A%22et.entrypoint-0_et.initial-0_et.chunk-1_et.asset-0%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View chunks")</span> | <span>`1`</span> | <span>`1`</span> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-gray.svg?sanitize=true" width="8" alt="No change" align="center">&nbsp;&nbsp;[Assets](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/assets?ba=%7B%22filters%22%3A%22changed-0%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View assets")</span> | <span>`1`</span> | <span>`1`</span> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-blue.svg?sanitize=true" width="8" alt="Change" align="center">&nbsp;&nbsp;[Modules](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/modules?bm=%7B%22metric%22%3A%22totalSize%22%2C%22filters%22%3A%22changed-0%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View modules")</span> | <span>`1472`(`+0.07%`)</span> | <span>`1471`</span> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-gray.svg?sanitize=true" width="8" alt="No change" align="center">&nbsp;&nbsp;[Duplicate Modules](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/modules?bm=%7B%22metric%22%3A%22duplicateSize%22%2C%22filters%22%3A%22changed-0_md-1%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View duplicate modules")</span> | <span>`0`</span> | <span>`0`</span> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-gray.svg?sanitize=true" width="8" alt="No change" align="center">&nbsp;&nbsp;[Duplicate Code](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/modules?bm=%7B%22metric%22%3A%22duplicateSize%22%2C%22filters%22%3A%22changed-0_md-1%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View duplicate modules")</span> | <span>`0%`</span> | <span>`0%`</span> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-gray.svg?sanitize=true" width="8" alt="No change" align="center">&nbsp;&nbsp;[Packages](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/packages?bp=%7B%22filters%22%3A%22changed-0%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View packages")</span> | <span>`172`</span> | <span>`172`</span> |
| <span><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-gray.svg?sanitize=true" width="8" alt="No change" align="center">&nbsp;&nbsp;[Duplicate Packages](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/packages?bp=%7B%22filters%22%3A%22duplicate-1%22%7D&utm_source=github&utm_content=summary&utm_campaign=pr-report "View duplicate packages")</span> | <span>`6`</span> | <span>`6`</span> |
</details>


<details>
<summary><strong>Bundle size by type</strong>&nbsp;&nbsp;<img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-blue.svg?sanitize=true" width="8" alt="Change" align="center"> <code>1 change</code> <img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-red.svg?sanitize=true" width="8" alt="Regression" align="center"> <code>1 regression</code></summary>
<br>


|            |       Current<br />[#8527](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb?utm_source=github&utm_content=totals&utm_campaign=pr-report "View bundle analysis report") |      Baseline<br />[#8524](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8524-szFwcs3mrhBmzZaEOUB7?utm_source=github&utm_content=totals&utm_campaign=pr-report "View baseline bundle analysis report") |
|:--|--:|--:|
| <strong><img src="https://raw.githubusercontent.com/relative-ci/relative-ci-github-app-assets/v0.0.4/reports/circle-red.svg?sanitize=true" width="8" alt="Regression" align="center">&nbsp;&nbsp;[JS](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb/assets?ba=%7B%22filters%22%3A%22ft.CSS-0_ft.JS-1_ft.IMG-0_ft.MEDIA-0_ft.FONT-0_ft.HTML-0_ft.OTHER-0%22%7D&utm_source=github&utm_content=totals&utm_campaign=pr-report "View JS assets")</strong> | <strong>`2.39MiB` (`+100%`)</strong> | <strong>`undefined`</strong> |
</details>


[Bundle analysis report](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/jobs/8527-uKYXn5Eo3QCMqpBSeKHb?utm_source=github&utm_content=report-footer&utm_campaign=pr-report) [Branch **brynn/doc-2627-api-client-reques...**](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX/tree/brynn%2Fdoc-2627-api-client-request-area-a11y?utm_source=github&utm_content=report-footer&utm_campaign=pr-report) [Project dashboard](https://app.relative-ci.com/projects/WjV7m24scsHglgIEsSBX?utm_source=github&utm_content=report-footer&utm_campaign=pr-report)---<sup>Generated by [RelativeCI](https://relative-ci.com/?utm_source=github&utm_content=report-footer&utm_campaign=pr-report) [Documentation](https://relative-ci.com/documentation?utm_source=github&utm_content=report-footer&utm_campaign=pr-report) [Report issue](https://github.com/relative-ci/roadmap/issues)</sup>
<!--RELATIVE_CI_PROJECT_NAME="scalar"-->
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-06T21:59:57Z
body: **Cloudflare Preview for the API Client**

https://57f2b986.client-staging-7m0.pages.dev
<!-- thollander/actions-comment-pull-request "cloudflare-preview" -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-06T22:00:10Z
body: **Preview Storybook**

https://38356725-ea08-457d-a088-353b5ee75297--scalar-components.netlify.app
<!-- thollander/actions-comment-pull-request "netlify-preview" -->
author_association: CONTRIBUTOR