-
Notifications
You must be signed in to change notification settings - Fork 298
Expand file tree
/
Copy pathChatSkipNavigation.tsx
More file actions
132 lines (118 loc) Β· 5.09 KB
/
Copy pathChatSkipNavigation.tsx
File metadata and controls
132 lines (118 loc) Β· 5.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import { useEffect, useMemo, useState } from 'react';
import { SkipNavigation } from 'stream-chat-react';
import {
CHANNEL_LIST_TARGET_ID,
CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
CHANNELS_SELECTOR_BUTTON_TARGET_QUERY,
THREAD_LIST_TARGET_ID,
THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID,
} from '../ChatLayout/Panels.tsx';
export const CHAT_SKIP_NAVIGATION_TARGET_ID = 'app-chat-skip-navigation';
// The list skip-link should land on the FIRST item (a focusable role="option" button with a visible
// focus ring), not on the listbox container β focusing a container gives no visual focus cue.
// Returns whether an option was actually focused: an empty/loading list has no `role="option"`, in
// which case the caller must NOT preventDefault, letting SkipNavigation fall back to focusing the
// list container (otherwise the link would be a dead end).
const focusFirstListOption = (listTargetId: string): boolean => {
const option = document.querySelector<HTMLElement>(`#${listTargetId} [role="option"]`);
option?.focus();
return !!option;
};
const isLinkFor = (target: EventTarget | null, targetId: string) =>
target instanceof HTMLAnchorElement && target.getAttribute('href') === `#${targetId}`;
const isActivationKey = (key: string) =>
key === 'Enter' || key === ' ' || key === 'Spacebar';
const getSkipNavigationLinkLabel = (targetId: string) => {
if (
targetId === CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID ||
targetId === THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
) {
return 'Skip to message composer';
}
if (targetId === CHANNEL_LIST_TARGET_ID) {
return 'Skip to channel list';
}
if (targetId === THREAD_LIST_TARGET_ID) {
return 'Skip to thread list';
}
return 'Skip to sidebar';
};
export const ChatSkipNavigation = () => {
// Skip targets are tagged imperatively onto SDK-owned DOM and depend on the active view (the
// channel list shows in the channels view, the thread list in the threads view) and the layout, so
// resolve them from the live DOM and keep them in sync via a MutationObserver. Only links whose
// target currently exists are rendered, so there are never dead skip links.
const [composerTargetId, setComposerTargetId] = useState<string | null>(null);
const [listTargetId, setListTargetId] = useState<string | null>(null);
const [selectorButtonTargetId, setSelectorButtonTargetId] = useState<string | null>(
null,
);
useEffect(() => {
const syncTargets = () => {
// The channel composer (channels view) and the thread composer (threads view) carry different
// ids; whichever is currently mounted is the composer skip target.
setComposerTargetId(
document.getElementById(CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID)
? CHANNEL_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
: document.getElementById(THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID)
? THREAD_MESSAGE_COMPOSER_TEXTAREA_TARGET_ID
: null,
);
// Exactly one of the two lists is mounted at a time, depending on the active view.
setListTargetId(
document.getElementById(THREAD_LIST_TARGET_ID)
? THREAD_LIST_TARGET_ID
: document.getElementById(CHANNEL_LIST_TARGET_ID)
? CHANNEL_LIST_TARGET_ID
: null,
);
setSelectorButtonTargetId(
document.querySelector<HTMLElement>(CHANNELS_SELECTOR_BUTTON_TARGET_QUERY)?.id ??
null,
);
};
syncTargets();
const observer = new MutationObserver(syncTargets);
observer.observe(document.body, {
attributes: true,
attributeFilter: ['id'],
childList: true,
subtree: true,
});
return () => observer.disconnect();
}, []);
// Ordered by keyboard priority: composer, then the active list, then the sidebar selector.
const targetIds = useMemo(
() =>
[composerTargetId, listTargetId, selectorButtonTargetId].filter(
(id): id is string => !!id,
),
[composerTargetId, listTargetId, selectorButtonTargetId],
);
return (
<nav aria-label='Chat quick navigation' id={CHAT_SKIP_NAVIGATION_TARGET_ID}>
<SkipNavigation
getLinkLabel={getSkipNavigationLinkLabel}
// Intercept the active list link (SkipNavigation's documented `onClick`/`onKeyDown` +
// `preventDefault` escape hatch) to focus the first item instead of the listbox container.
// Other links fall through to SkipNavigation's default focus handling.
onClick={(event) => {
if (!listTargetId || !isLinkFor(event.currentTarget, listTargetId)) return;
// Only take over when there is an option to focus; otherwise let SkipNavigation focus the
// list container so the link is never a dead end.
if (focusFirstListOption(listTargetId)) event.preventDefault();
}}
onKeyDown={(event) => {
if (
!listTargetId ||
!isLinkFor(event.currentTarget, listTargetId) ||
!isActivationKey(event.key)
)
return;
if (focusFirstListOption(listTargetId)) event.preventDefault();
}}
targetIds={targetIds}
/>
</nav>
);
};