11import { ActionList , ActionMenu , Flash } from '@primer/react'
2- import { useState , useEffect } from 'react'
2+ import { useState , useEffect , useCallback } from 'react'
33import useSWR from 'swr'
4- import { useRouter } from 'next/router'
54import { slug } from 'github-slugger'
65import cx from 'classnames'
76import { 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