@@ -5,37 +5,85 @@ import {
55 CHANNEL_LIST_TARGET_ID ,
66 CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID ,
77 CHANNELS_SELECTOR_BUTTON_TARGET_QUERY ,
8+ THREAD_LIST_TARGET_ID ,
9+ THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID ,
810} from '../ChatLayout/Panels.tsx' ;
911
1012export const CHAT_SKIP_NAVIGATION_TARGET_ID = 'app-chat-skip-navigation' ;
1113
14+ // The list skip-link should land on the FIRST item (a focusable role="option" button with a visible
15+ // focus ring), not on the listbox container — focusing a container gives no visual focus cue.
16+ // Returns whether an option was actually focused: an empty/loading list has no `role="option"`, in
17+ // which case the caller must NOT preventDefault, letting SkipNavigation fall back to focusing the
18+ // list container (otherwise the link would be a dead end).
19+ const focusFirstListOption = ( listTargetId : string ) : boolean => {
20+ const option = document . querySelector < HTMLElement > ( `#${ listTargetId } [role="option"]` ) ;
21+ option ?. focus ( ) ;
22+ return ! ! option ;
23+ } ;
24+
25+ const isLinkFor = ( target : EventTarget | null , targetId : string ) =>
26+ target instanceof HTMLAnchorElement && target . getAttribute ( 'href' ) === `#${ targetId } ` ;
27+
28+ const isActivationKey = ( key : string ) =>
29+ key === 'Enter' || key === ' ' || key === 'Spacebar' ;
30+
1231const getSkipNavigationLinkLabel = ( targetId : string ) => {
13- if ( targetId === CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID ) {
32+ if (
33+ targetId === CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID ||
34+ targetId === THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
35+ ) {
1436 return 'Skip to message composer' ;
1537 }
1638 if ( targetId === CHANNEL_LIST_TARGET_ID ) {
1739 return 'Skip to channel list' ;
1840 }
41+ if ( targetId === THREAD_LIST_TARGET_ID ) {
42+ return 'Skip to thread list' ;
43+ }
1944
2045 return 'Skip to sidebar' ;
2146} ;
2247
2348export const ChatSkipNavigation = ( ) => {
24- const [ channelsSelectorButtonTargetId , setChannelsSelectorButtonTargetId ] = useState <
25- string | null
26- > ( null ) ;
49+ // Skip targets are tagged imperatively onto SDK-owned DOM and depend on the active view (the
50+ // channel list shows in the channels view, the thread list in the threads view) and the layout, so
51+ // resolve them from the live DOM and keep them in sync via a MutationObserver. Only links whose
52+ // target currently exists are rendered, so there are never dead skip links.
53+ const [ composerTargetId , setComposerTargetId ] = useState < string | null > ( null ) ;
54+ const [ listTargetId , setListTargetId ] = useState < string | null > ( null ) ;
55+ const [ selectorButtonTargetId , setSelectorButtonTargetId ] = useState < string | null > (
56+ null ,
57+ ) ;
2758
2859 useEffect ( ( ) => {
29- const syncChannelsSelectorButtonTargetId = ( ) => {
30- const channelsSelectorButton = document . querySelector < HTMLElement > (
31- CHANNELS_SELECTOR_BUTTON_TARGET_QUERY ,
60+ const syncTargets = ( ) => {
61+ // The channel composer (channels view) and the thread composer (threads view) carry different
62+ // ids; whichever is currently mounted is the composer skip target.
63+ setComposerTargetId (
64+ document . getElementById ( CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID )
65+ ? CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
66+ : document . getElementById ( THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID )
67+ ? THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
68+ : null ,
69+ ) ;
70+ // Exactly one of the two lists is mounted at a time, depending on the active view.
71+ setListTargetId (
72+ document . getElementById ( THREAD_LIST_TARGET_ID )
73+ ? THREAD_LIST_TARGET_ID
74+ : document . getElementById ( CHANNEL_LIST_TARGET_ID )
75+ ? CHANNEL_LIST_TARGET_ID
76+ : null ,
77+ ) ;
78+ setSelectorButtonTargetId (
79+ document . querySelector < HTMLElement > ( CHANNELS_SELECTOR_BUTTON_TARGET_QUERY ) ?. id ??
80+ null ,
3281 ) ;
33- setChannelsSelectorButtonTargetId ( channelsSelectorButton ?. id ?? null ) ;
3482 } ;
3583
36- syncChannelsSelectorButtonTargetId ( ) ;
84+ syncTargets ( ) ;
3785
38- const observer = new MutationObserver ( syncChannelsSelectorButtonTargetId ) ;
86+ const observer = new MutationObserver ( syncTargets ) ;
3987 observer . observe ( document . body , {
4088 attributes : true ,
4189 attributeFilter : [ 'id' ] ,
@@ -46,22 +94,39 @@ export const ChatSkipNavigation = () => {
4694 return ( ) => observer . disconnect ( ) ;
4795 } , [ ] ) ;
4896
49- const targetIds = useMemo ( ( ) => {
50- const skipTargets = [
51- CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID ,
52- CHANNEL_LIST_TARGET_ID ,
53- ] ;
54-
55- if ( channelsSelectorButtonTargetId ) {
56- skipTargets . push ( channelsSelectorButtonTargetId ) ;
57- }
58-
59- return skipTargets ;
60- } , [ channelsSelectorButtonTargetId ] ) ;
97+ // Ordered by keyboard priority: composer, then the active list, then the sidebar selector.
98+ const targetIds = useMemo (
99+ ( ) =>
100+ [ composerTargetId , listTargetId , selectorButtonTargetId ] . filter (
101+ ( id ) : id is string => ! ! id ,
102+ ) ,
103+ [ composerTargetId , listTargetId , selectorButtonTargetId ] ,
104+ ) ;
61105
62106 return (
63107 < nav aria-label = 'Chat quick navigation' id = { CHAT_SKIP_NAVIGATION_TARGET_ID } >
64- < SkipNavigation getLinkLabel = { getSkipNavigationLinkLabel } targetIds = { targetIds } />
108+ < SkipNavigation
109+ getLinkLabel = { getSkipNavigationLinkLabel }
110+ // Intercept the active list link (SkipNavigation's documented `onClick`/`onKeyDown` +
111+ // `preventDefault` escape hatch) to focus the first item instead of the listbox container.
112+ // Other links fall through to SkipNavigation's default focus handling.
113+ onClick = { ( event ) => {
114+ if ( ! listTargetId || ! isLinkFor ( event . currentTarget , listTargetId ) ) return ;
115+ // Only take over when there is an option to focus; otherwise let SkipNavigation focus the
116+ // list container so the link is never a dead end.
117+ if ( focusFirstListOption ( listTargetId ) ) event . preventDefault ( ) ;
118+ } }
119+ onKeyDown = { ( event ) => {
120+ if (
121+ ! listTargetId ||
122+ ! isLinkFor ( event . currentTarget , listTargetId ) ||
123+ ! isActivationKey ( event . key )
124+ )
125+ return ;
126+ if ( focusFirstListOption ( listTargetId ) ) event . preventDefault ( ) ;
127+ } }
128+ targetIds = { targetIds }
129+ />
65130 </ nav >
66131 ) ;
67132} ;
0 commit comments