Skip to content

Commit 2b1d62b

Browse files
stevesdocs-botCopilot
authored
fix: announce webhook content changes to screen readers on every action type switch (#61852)
Co-authored-by: docs-bot <77750099+docs-bot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 28b8986 commit 2b1d62b

3 files changed

Lines changed: 63 additions & 33 deletions

File tree

data/ui.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ secret_scanning:
293293
webhooks:
294294
action_type_switch_error: There was an error switching webhook action types.
295295
action_type: Action type
296+
action_type_selected: "'{{ actionType }}' action selected. {{ description }}"
296297
availability: Availability for <code>{{ WebhookName }}</code>
297298
webhook_payload_object: Webhook payload object for <code>{{ WebhookName }}</code>
298299
webhook_payload_example: Webhook payload example

src/fixtures/fixtures/data/ui.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ secret_scanning:
293293
webhooks:
294294
action_type_switch_error: There was an error switching webhook action types.
295295
action_type: Action type
296+
action_type_selected: "'{{ actionType }}' action selected. {{ description }}"
296297
availability: Availability for <code>{{ WebhookName }}</code>
297298
webhook_payload_object: Webhook payload object for <code>{{ WebhookName }}</code>
298299
webhook_payload_example: Webhook payload example

src/webhooks/components/Webhook.tsx

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { ActionList, ActionMenu, Flash } from '@primer/react'
2-
import { useState, useEffect } from 'react'
2+
import { useState, useEffect, useCallback } from 'react'
33
import useSWR from 'swr'
4-
import { useRouter } from 'next/router'
54
import { slug } from 'github-slugger'
65
import cx from 'classnames'
76
import { announce } from '@primer/live-region-element'
@@ -33,7 +32,6 @@ export function Webhook({ webhook }: Props) {
3332
// Get version for requests to switch webhook action type
3433
const version = useVersion()
3534
const { t, tObject } = useTranslation('webhooks')
36-
const router = useRouter()
3735

3836
// Get more user friendly language for the different availability options in
3937
// the webhook schema (we can't change it directly in the schema). Note that
@@ -48,13 +46,28 @@ export function Webhook({ webhook }: Props) {
4846
// The index of the selected action type so we can highlight which one is selected
4947
// in the action type dropdown
5048
const [selectedActionTypeIndex, setSelectedActionTypeIndex] = useState(0)
49+
// Tracks whether we need to announce once data loads (first interaction only,
50+
// before SWR cache is populated).
51+
const [pendingAnnouncement, setPendingAnnouncement] = useState('')
5152

5253
const webhookSlug = slug(webhook.data.category)
5354
const webhookFetchUrl = `/api/webhooks/v1?${new URLSearchParams({
5455
category: webhook.data.category,
5556
version: version.currentVersion,
5657
})}`
5758

59+
// fires when the webhook action type changes or someone clicks on a nested
60+
// body param for the first time. In either case, we now have all the data
61+
// for a webhook (i.e. all the data for each action type and all of their
62+
// nested parameters)
63+
const { data, error } = useSWR<WebhookData, Error>(
64+
clickedBodyParameterName || selectedWebhookActionType ? webhookFetchUrl : null,
65+
webhookFetcher,
66+
{
67+
revalidateOnFocus: false,
68+
},
69+
)
70+
5871
// When you load the page we want to support linking to a specific webhook type
5972
// so this effect sets the webhook type if it's provided in the URL e.g.:
6073
//
@@ -72,6 +85,20 @@ export function Webhook({ webhook }: Props) {
7285
}
7386
}, [])
7487

88+
// Build a plain-text announcement from the webhook action data.
89+
const buildAnnouncement = useCallback(
90+
(type: string, actionData: { descriptionHtml: string }) => {
91+
const tempEl = document.createElement('div')
92+
tempEl.innerHTML = actionData.descriptionHtml
93+
const description = tempEl.textContent?.trim() || ''
94+
return t('action_type_selected')
95+
.replace('{{ actionType }}', type)
96+
.replace('{{ description }}', description)
97+
.trim()
98+
},
99+
[t],
100+
)
101+
75102
// callback for the action type dropdown -- sets the action type to the given
76103
// type, index is the index of the selected type so we can highlight it as
77104
// selected.
@@ -86,48 +113,49 @@ export function Webhook({ webhook }: Props) {
86113
setSelectedWebhookActionType(type)
87114
setSelectedActionTypeIndex(index)
88115

89-
// Announce the newly selected action type to screen readers so users
90-
// relying on AT know the page content has changed.
91-
announce(`${t('action_type')}: ${type}`, { politeness: 'polite' })
92-
93-
const { asPath, locale } = router
94-
let [pathRoot, pathQuery = ''] = asPath.split('?')
95-
const params = new URLSearchParams(pathQuery)
96-
97-
if (pathRoot.includes('#')) {
98-
pathRoot = pathRoot.split('#')[0]
116+
// If SWR data is already cached, announce immediately. Otherwise, flag
117+
// the type so the effect can announce once data arrives.
118+
if (data && data[type]) {
119+
// Use setTimeout so the announcement fires after the ActionMenu closes
120+
// and VoiceOver finishes reading the button. Compute message eagerly to
121+
// avoid stale closures if data changes before the timeout fires.
122+
const message = buildAnnouncement(type, data[type])
123+
setTimeout(() => {
124+
announce(message, { politeness: 'assertive' })
125+
}, 150)
126+
} else {
127+
setPendingAnnouncement(type)
99128
}
100129

101-
params.set('actionType', type)
102-
router.push(
103-
{ pathname: `/${locale}${pathRoot}`, query: params.toString(), hash: webhookSlug },
104-
undefined,
105-
{
106-
shallow: true,
107-
},
108-
)
130+
// Update the URL without triggering Next.js router navigation, which causes
131+
// VoiceOver to re-read the page title and swallow live-region announcements.
132+
const url = new URL(location.href)
133+
url.searchParams.set('actionType', type)
134+
url.hash = webhookSlug
135+
window.history.replaceState(window.history.state, '', url.toString())
109136
}
110137

111138
// callback to trigger useSWR() hook after a nested property is clicked
112139
function handleBodyParamExpansion(target: HTMLDetailsElement) {
113140
setClickedBodyParameterName(target.closest('details')?.dataset.nestedParamId)
114141
}
115142

116-
// fires when the webhook action type changes or someone clicks on a nested
117-
// body param for the first time. In either case, we now have all the data
118-
// for a webhook (i.e. all the data for each action type and all of their
119-
// nested parameters)
120-
const { data, error } = useSWR<WebhookData, Error>(
121-
clickedBodyParameterName || selectedWebhookActionType ? webhookFetchUrl : null,
122-
webhookFetcher,
123-
{
124-
revalidateOnFocus: false,
125-
},
126-
)
127-
128143
const currentWebhookActionType = selectedWebhookActionType || webhook.data.action
129144
const currentWebhookAction = (data && data[currentWebhookActionType]) || webhook.data
130145

146+
// Announce content changes when data arrives for the first time (before SWR
147+
// cache is populated). Subsequent changes are announced directly in the handler.
148+
useEffect(() => {
149+
if (!pendingAnnouncement || !data || !data[pendingAnnouncement]) return
150+
const type = pendingAnnouncement
151+
setPendingAnnouncement('')
152+
153+
const message = buildAnnouncement(type, data[type])
154+
setTimeout(() => {
155+
announce(message, { politeness: 'assertive' })
156+
}, 150)
157+
}, [data, pendingAnnouncement, buildAnnouncement])
158+
131159
return (
132160
<div>
133161
<HeadingLink as="h2" slug={webhookSlug}>

0 commit comments

Comments
 (0)