Browse Source

Use tabs ARIA design pattern when using tabbed UI (#12994)

feat(a11y): use tabs ARIA design pattern when using tabbed UI
factor2
Emmanuel Pelletier 2 years ago
parent
commit
f727b9295f
No account linked to committer's email address

+ 8
- 7
css/_meetings_list.scss View File

@@ -82,6 +82,7 @@
82 82
         }
83 83
 
84 84
         .left-column {
85
+            order: -1;
85 86
             display: flex;
86 87
             flex-direction: column;
87 88
             flex-grow: 0;
@@ -92,6 +93,7 @@
92 93
         .right-column {
93 94
             display: flex;
94 95
             flex-direction: column;
96
+            align-items: flex-start;
95 97
             flex-grow: 1;
96 98
             padding-left: 16px;
97 99
             padding-top: 13px;
@@ -99,11 +101,11 @@
99 101
         }
100 102
 
101 103
         .title {
102
-                font-size: 12px;
103
-                font-weight: 600;
104
-                line-height: 16px;
105
-                padding-bottom: 4px;
106
-            }
104
+            font-size: 12px;
105
+            font-weight: 600;
106
+            line-height: 16px;
107
+            margin-bottom: 4px;
108
+        }
107 109
 
108 110
         .subtitle {
109 111
             color: #5E6D7A;
@@ -125,8 +127,7 @@
125 127
             cursor: pointer;
126 128
         }
127 129
 
128
-        &.with-click-handler:hover,
129
-        &.with-click-handler:focus {
130
+        &.with-click-handler:hover {
130 131
             background-color: #c7ddff;
131 132
         }
132 133
 

+ 1
- 1
css/_polls.scss View File

@@ -1,3 +1,3 @@
1
-#polls-panel {
1
+.polls-panel {
2 2
     height: calc(100% - 119px);
3 3
 }

+ 4
- 2
css/_welcome_page.scss View File

@@ -167,7 +167,7 @@ body.welcome-page {
167 167
             margin: 4px;
168 168
             display: $welcomePageTabButtonsDisplay;
169 169
 
170
-            .tab {
170
+            [role="tab"] {
171 171
                 background-color: #c7ddff;
172 172
                 border-radius: 7px;
173 173
                 cursor: pointer;
@@ -176,8 +176,10 @@ body.welcome-page {
176 176
                 margin: 2px;
177 177
                 padding: 7px 0;
178 178
                 text-align: center;
179
+                color: inherit;
180
+                border: 0;
179 181
 
180
-                &.selected {
182
+                &[aria-selected="true"] {
181 183
                     background-color: #FFF;
182 184
                 }
183 185
             }

+ 4
- 1
lang/main.json View File

@@ -240,7 +240,9 @@
240 240
         "WaitingForHostTitle": "Waiting for the host ...",
241 241
         "Yes": "Yes",
242 242
         "accessibilityLabel": {
243
-            "liveStreaming": "Live Stream"
243
+            "close": "Close dialog",
244
+            "liveStreaming": "Live Stream",
245
+            "sharingTabs": "Sharing options"
244 246
         },
245 247
         "add": "Add",
246 248
         "addMeetingNote": "Add a note about this meeting",
@@ -1387,6 +1389,7 @@
1387 1389
             "microsoftLogo": "Microsoft logo",
1388 1390
             "policyLogo": "Policy logo"
1389 1391
         },
1392
+        "meetingsAccessibilityLabel": "Meetings",
1390 1393
         "mobileDownLoadLinkAndroid": "Download mobile app for Android",
1391 1394
         "mobileDownLoadLinkFDroid": "Download mobile app for F-Droid",
1392 1395
         "mobileDownLoadLinkIos": "Download mobile app for iOS",

+ 17
- 19
react/features/base/react/components/web/MeetingsList.js View File

@@ -105,17 +105,14 @@ class MeetingsList extends Component<Props> {
105 105
      * @returns {React.ReactNode}
106 106
      */
107 107
     render() {
108
-        const { listEmptyComponent, meetings, t } = this.props;
108
+        const { listEmptyComponent, meetings } = this.props;
109 109
 
110 110
         /**
111 111
          * If there are no recent meetings we don't want to display anything.
112 112
          */
113 113
         if (meetings) {
114 114
             return (
115
-                <Container
116
-                    aria-label = { t('welcomepage.recentList') }
117
-                    className = 'meetings-list'
118
-                    tabIndex = '-1'>
115
+                <Container className = 'meetings-list'>
119 116
                     {
120 117
                         meetings.length === 0
121 118
                             ? listEmptyComponent
@@ -237,23 +234,16 @@ class MeetingsList extends Component<Props> {
237 234
 
238 235
         return (
239 236
             <Container
240
-                aria-label = { title }
241 237
                 className = { rootClassName }
242 238
                 key = { index }
243
-                onClick = { onPress }
244
-                onKeyPress = { onKeyPress }
245
-                role = 'button'
246
-                tabIndex = { 0 }>
247
-                <Container className = 'left-column'>
248
-                    <Text className = 'title'>
249
-                        { _toDateString(date) }
250
-                    </Text>
251
-                    <Text className = 'subtitle'>
252
-                        { _toTimeString(time) }
253
-                    </Text>
254
-                </Container>
239
+                onClick = { onPress }>
255 240
                 <Container className = 'right-column'>
256
-                    <Text className = 'title'>
241
+                    <Text
242
+                        className = 'title'
243
+                        onClick = { onPress }
244
+                        onKeyPress = { onKeyPress }
245
+                        role = 'button'
246
+                        tabIndex = { 0 }>
257 247
                         { title }
258 248
                     </Text>
259 249
                     {
@@ -269,6 +259,14 @@ class MeetingsList extends Component<Props> {
269 259
                             </Text>) : null
270 260
                     }
271 261
                 </Container>
262
+                <Container className = 'left-column'>
263
+                    <Text className = 'title'>
264
+                        { _toDateString(date) }
265
+                    </Text>
266
+                    <Text className = 'subtitle'>
267
+                        { _toTimeString(time) }
268
+                    </Text>
269
+                </Container>
272 270
                 <Container className = 'actions'>
273 271
                     { elementAfter || null }
274 272
 

+ 22
- 3
react/features/base/ui/components/web/ContextMenuItem.tsx View File

@@ -26,6 +26,13 @@ export interface IProps {
26 26
      */
27 27
     className?: string;
28 28
 
29
+    /**
30
+     * Id of dom element controlled by this item. Matches aria-controls.
31
+     * Useful if you need this item as a tab element.
32
+     *
33
+     */
34
+    controls?: string;
35
+
29 36
     /**
30 37
      * Custom icon. If used, the icon prop is ignored.
31 38
      * Used to allow custom children instead of just the default icons.
@@ -55,7 +62,7 @@ export interface IProps {
55 62
     /**
56 63
      * Keydown handler.
57 64
      */
58
-    onKeyDown?: (e?: React.KeyboardEvent) => void;
65
+    onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
59 66
 
60 67
     /**
61 68
      * Keypress handler.
@@ -67,6 +74,11 @@ export interface IProps {
67 74
      */
68 75
     overflowType?: TEXT_OVERFLOW_TYPES;
69 76
 
77
+    /**
78
+     * You can use this item as a tab. Defaults to button if not set.
79
+     */
80
+    role?: 'tab' | 'button';
81
+
70 82
     /**
71 83
      * Whether the item is marked as selected.
72 84
      */
@@ -150,6 +162,7 @@ const ContextMenuItem = ({
150 162
     accessibilityLabel,
151 163
     children,
152 164
     className,
165
+    controls,
153 166
     customIcon,
154 167
     disabled,
155 168
     id,
@@ -158,6 +171,7 @@ const ContextMenuItem = ({
158 171
     onKeyDown,
159 172
     onKeyPress,
160 173
     overflowType,
174
+    role = 'button',
161 175
     selected,
162 176
     testId,
163 177
     text,
@@ -167,8 +181,10 @@ const ContextMenuItem = ({
167 181
 
168 182
     return (
169 183
         <div
184
+            aria-controls = { controls }
170 185
             aria-disabled = { disabled }
171 186
             aria-label = { accessibilityLabel }
187
+            aria-selected = { role === 'tab' ? selected : undefined }
172 188
             className = { cx(styles.contextMenuItem,
173 189
                     _overflowDrawer && styles.contextMenuItemDrawer,
174 190
                     disabled && styles.contextMenuItemDisabled,
@@ -181,8 +197,11 @@ const ContextMenuItem = ({
181 197
             onClick = { disabled ? undefined : onClick }
182 198
             onKeyDown = { disabled ? undefined : onKeyDown }
183 199
             onKeyPress = { disabled ? undefined : onKeyPress }
184
-            role = 'button'
185
-            tabIndex = { disabled ? undefined : 0 }>
200
+            role = { role }
201
+            tabIndex = { role === 'tab'
202
+                ? selected ? 0 : -1
203
+                : disabled ? undefined : 0
204
+            }>
186 205
             {customIcon ? customIcon
187 206
                 : icon && <Icon
188 207
                     className = { styles.contextMenuItemIcon }

+ 1
- 1
react/features/base/ui/components/web/Dialog.tsx View File

@@ -131,7 +131,7 @@ const Dialog = ({
131 131
                 </p>
132 132
                 {!hideCloseButton && (
133 133
                     <ClickableIcon
134
-                        accessibilityLabel = { t('dialog.close') }
134
+                        accessibilityLabel = { t('dialog.accessibilityLabel.close') }
135 135
                         icon = { IconCloseLarge }
136 136
                         id = 'modal-header-close-button'
137 137
                         onClick = { onClose } />

+ 104
- 22
react/features/base/ui/components/web/DialogWithTabs.tsx View File

@@ -1,4 +1,5 @@
1 1
 import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
2
+import { MoveFocusInside } from 'react-focus-lock';
2 3
 import { useTranslation } from 'react-i18next';
3 4
 import { useDispatch, useSelector } from 'react-redux';
4 5
 import { makeStyles } from 'tss-react/mui';
@@ -89,10 +90,6 @@ const useStyles = makeStyles()(theme => {
89 90
             }
90 91
         },
91 92
 
92
-        closeButtonContainer: {
93
-            paddingBottom: theme.spacing(4)
94
-        },
95
-
96 93
         buttonContainer: {
97 94
             width: '100%',
98 95
             boxSizing: 'border-box',
@@ -109,6 +106,7 @@ const useStyles = makeStyles()(theme => {
109 106
 
110 107
         backContainer: {
111 108
             display: 'flex',
109
+            flexDirection: 'row-reverse',
112 110
             alignItems: 'center',
113 111
 
114 112
             '& > button': {
@@ -127,6 +125,11 @@ const useStyles = makeStyles()(theme => {
127 125
             }
128 126
         },
129 127
 
128
+        header: {
129
+            order: -1,
130
+            paddingBottom: theme.spacing(4)
131
+        },
132
+
130 133
         footer: {
131 134
             justifyContent: 'flex-end',
132 135
             paddingTop: theme.spacing(4),
@@ -168,6 +171,7 @@ const DialogWithTabs = ({
168 171
     const dispatch = useDispatch();
169 172
     const { t } = useTranslation();
170 173
     const [ selectedTab, setSelectedTab ] = useState<string | undefined>(defaultTab ?? tabs[0].name);
174
+    const [ userSelected, setUserSelected ] = useState(false);
171 175
     const [ tabStates, setTabStates ] = useState(tabs.map(tab => tab.props));
172 176
     const clientWidth = useSelector((state: IReduxState) => state['features/base/responsive-ui'].clientWidth);
173 177
     const [ isMobile, setIsMobile ] = useState(false);
@@ -188,18 +192,58 @@ const DialogWithTabs = ({
188 192
         }
189 193
     }, [ isMobile ]);
190 194
 
195
+    const onUserSelection = useCallback((tabName?: string) => {
196
+        setUserSelected(true);
197
+        setSelectedTab(tabName);
198
+    }, []);
199
+
191 200
     const back = useCallback(() => {
192
-        setSelectedTab(undefined);
201
+        onUserSelection(undefined);
193 202
     }, []);
194 203
 
204
+
205
+    // the userSelected state is used to prevent setting focus when the user
206
+    // didn't actually interact (for the first rendering for example)
207
+    useEffect(() => {
208
+        if (userSelected) {
209
+            document.querySelector<HTMLElement>(isMobile
210
+                ? `.${classes.title}`
211
+                : `#${`dialogtab-button-${selectedTab}`}`
212
+            )?.focus();
213
+            setUserSelected(false);
214
+        }
215
+    }, [ isMobile, userSelected, selectedTab ]);
216
+
195 217
     const onClose = useCallback(() => {
196 218
         dispatch(hideDialog());
197 219
     }, []);
198 220
 
199 221
     const onClick = useCallback((tabName: string) => () => {
200
-        setSelectedTab(tabName);
222
+        onUserSelection(tabName);
201 223
     }, []);
202 224
 
225
+    const onTabKeyDown = useCallback((index: number) => (event: React.KeyboardEvent<HTMLDivElement>) => {
226
+        let newTab: IDialogTab<any> | null = null;
227
+
228
+        if (event.key === 'ArrowUp') {
229
+            newTab = index === 0 ? tabs[tabs.length - 1] : tabs[index - 1];
230
+        }
231
+
232
+        if (event.key === 'ArrowDown') {
233
+            newTab = index === tabs.length - 1 ? tabs[0] : tabs[index + 1];
234
+        }
235
+
236
+        if (newTab !== null) {
237
+            onUserSelection(newTab.name);
238
+        }
239
+    }, [ tabs.length ]);
240
+
241
+    const onMobileKeyDown = useCallback((tabName: string) => (event: React.KeyboardEvent<HTMLDivElement>) => {
242
+        if (event.key === ' ' || event.key === 'Enter') {
243
+            onUserSelection(tabName);
244
+        }
245
+    }, [ classes.contentContainer ]);
246
+
203 247
     const getTabProps = (tabId: number) => {
204 248
         const tabConfiguration = tabs[tabId];
205 249
         const currentTabState = tabStates[tabId];
@@ -256,7 +300,7 @@ const DialogWithTabs = ({
256 300
 
257 301
     const closeIcon = useMemo(() => (
258 302
         <ClickableIcon
259
-            accessibilityLabel = { t('dialog.close') }
303
+            accessibilityLabel = { t('dialog.accessibilityLabel.close') }
260 304
             icon = { IconCloseLarge }
261 305
             id = 'modal-header-close-button'
262 306
             onClick = { onClose } />
@@ -268,21 +312,39 @@ const DialogWithTabs = ({
268 312
             onClose = { onClose }
269 313
             size = 'large'>
270 314
             {(!isMobile || !selectedTab) && (
271
-                <div className = { classes.sidebar }>
315
+                <div
316
+                    aria-orientation = 'vertical'
317
+                    className = { classes.sidebar }
318
+                    role = { isMobile ? undefined : 'tablist' }>
272 319
                     <div className = { classes.titleContainer }>
273
-                        <h2 className = { classes.title }>{t(titleKey ?? '')}</h2>
320
+                        <MoveFocusInside>
321
+                            <h2
322
+                                className = { classes.title }
323
+                                tabIndex = { -1 }>
324
+                                {t(titleKey ?? '')}
325
+                            </h2>
326
+                        </MoveFocusInside>
274 327
                         {isMobile && closeIcon}
275 328
                     </div>
276
-                    {tabs.map(tab => {
329
+                    {tabs.map((tab, index) => {
277 330
                         const label = t(tab.labelKey);
278 331
 
332
+                        /**
333
+                         * When not on mobile, the items behave as tabs,
334
+                         * that's why we set `controls`, `role` and `selected` attributes
335
+                         * only when not on mobile, they are useful only for the tab behavior.
336
+                         */
279 337
                         return (
280 338
                             <ContextMenuItem
281 339
                                 accessibilityLabel = { label }
282 340
                                 className = { cx(isMobile && classes.menuItemMobile) }
341
+                                controls = { isMobile ? undefined : `dialogtab-content-${tab.name}` }
283 342
                                 icon = { tab.icon }
343
+                                id = { `dialogtab-button-${tab.name}` }
284 344
                                 key = { tab.name }
285 345
                                 onClick = { onClick(tab.name) }
346
+                                onKeyDown = { isMobile ? onMobileKeyDown(tab.name) : onTabKeyDown(index) }
347
+                                role = { isMobile ? undefined : 'tab' }
286 348
                                 selected = { tab.name === selectedTab }
287 349
                                 text = { label } />
288 350
                         );
@@ -290,25 +352,45 @@ const DialogWithTabs = ({
290 352
                 </div>
291 353
             )}
292 354
             {(!isMobile || selectedTab) && (
293
-                <div className = { classes.contentContainer }>
294
-                    <div className = { cx(classes.buttonContainer, classes.closeButtonContainer) }>
295
-                        {isMobile && (
355
+                <div
356
+                    className = { classes.contentContainer }
357
+                    tabIndex = { isMobile ? -1 : undefined }>
358
+                    {/* DOM order is important for keyboard users: show whole heading first when on mobile… */}
359
+                    {isMobile && (
360
+                        <div className = { cx(classes.buttonContainer, classes.header) }>
296 361
                             <span className = { classes.backContainer }>
362
+                                <h2
363
+                                    className = { classes.title }
364
+                                    tabIndex = { -1 }>
365
+                                    {(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
366
+                                </h2>
297 367
                                 <ClickableIcon
298 368
                                     accessibilityLabel = { t('dialog.Back') }
299 369
                                     icon = { IconArrowBack }
300 370
                                     id = 'modal-header-back-button'
301 371
                                     onClick = { back } />
302
-                                <h2 className = { classes.title }>
303
-                                    {(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
304
-                                </h2>
305 372
                             </span>
306
-                        )}
307
-                        {closeIcon}
308
-                    </div>
309
-                    <div className = { classes.content }>
310
-                        {selectedTabComponent}
311
-                    </div>
373
+                            {closeIcon}
374
+                        </div>
375
+                    )}
376
+                    {tabs.map(tab => (
377
+                        <div
378
+                            aria-labelledby = { isMobile ? undefined : `${tab.name}-button` }
379
+                            className = { cx(classes.content, tab.name !== selectedTab && 'hide') }
380
+                            id = { `dialogtab-content-${tab.name}` }
381
+                            key = { tab.name }
382
+                            role = { isMobile ? undefined : 'tabpanel' }
383
+                            tabIndex = { isMobile ? -1 : 0 }>
384
+                            { tab.name === selectedTab && selectedTabComponent }
385
+                        </div>
386
+                    ))}
387
+                    {/* But show the close button *after* tab panels when not on mobile (using tabs).
388
+                    This is so that we can tab back and forth tab buttons and tab panels easily. */}
389
+                    {!isMobile && (
390
+                        <div className = { cx(classes.buttonContainer, classes.header) }>
391
+                            {closeIcon}
392
+                        </div>
393
+                    )}
312 394
                     <div
313 395
                         className = { cx(classes.buttonContainer, classes.footer) }>
314 396
                         <Button

+ 34
- 7
react/features/base/ui/components/web/Tabs.tsx View File

@@ -1,4 +1,4 @@
1
-import React, { useCallback } from 'react';
1
+import React, { useCallback, useEffect } from 'react';
2 2
 import { makeStyles } from 'tss-react/mui';
3 3
 
4 4
 import { isMobileBrowser } from '../../../environment/utils';
@@ -11,6 +11,7 @@ interface ITabProps {
11 11
     selected: string;
12 12
     tabs: Array<{
13 13
         accessibilityLabel: string;
14
+        controlsId: string;
14 15
         countBadge?: number;
15 16
         disabled?: boolean;
16 17
         id: string;
@@ -87,26 +88,52 @@ const Tabs = ({
87 88
 }: ITabProps) => {
88 89
     const { classes, cx } = useStyles();
89 90
     const isMobile = isMobileBrowser();
90
-
91
-    const handleChange = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
92
-        onChange(e.currentTarget.id);
91
+    const onClick = useCallback(id => () => {
92
+        onChange(id);
93 93
     }, []);
94
+    const onKeyDown = useCallback((index: number) => (event: React.KeyboardEvent<HTMLButtonElement>) => {
95
+        let newIndex: number | null = null;
96
+
97
+        if (event.key === 'ArrowLeft') {
98
+            event.preventDefault();
99
+            newIndex = index === 0 ? tabs.length - 1 : index - 1;
100
+        }
101
+
102
+        if (event.key === 'ArrowRight') {
103
+            event.preventDefault();
104
+            newIndex = index === tabs.length - 1 ? 0 : index + 1;
105
+        }
106
+
107
+        if (newIndex !== null) {
108
+            onChange(tabs[newIndex].id);
109
+        }
110
+    }, [ tabs ]);
111
+
112
+    useEffect(() => {
113
+        // this test is needed to make sure the effect is triggered because of user actually changing tab
114
+        if (document.activeElement?.getAttribute('role') === 'tab') {
115
+            document.querySelector<HTMLButtonElement>(`#${selected}`)?.focus();
116
+        }
117
+    }, [ selected ]);
94 118
 
95 119
     return (
96 120
         <div
97 121
             aria-label = { accessibilityLabel }
98 122
             className = { cx(classes.container, className) }
99 123
             role = 'tablist'>
100
-            {tabs.map(tab => (
124
+            {tabs.map((tab, index) => (
101 125
                 <button
126
+                    aria-controls = { tab.controlsId }
102 127
                     aria-label = { tab.accessibilityLabel }
103 128
                     aria-selected = { selected === tab.id }
104 129
                     className = { cx(classes.tab, selected === tab.id && 'selected', isMobile && 'is-mobile') }
105 130
                     disabled = { tab.disabled }
106 131
                     id = { tab.id }
107 132
                     key = { tab.id }
108
-                    onClick = { handleChange }
109
-                    role = 'tab'>
133
+                    onClick = { onClick(tab.id) }
134
+                    onKeyDown = { onKeyDown(index) }
135
+                    role = 'tab'
136
+                    tabIndex = { selected === tab.id ? undefined : -1 }>
110 137
                     {tab.label}
111 138
                     {tab.countBadge && <span className = { classes.badge }>{tab.countBadge}</span>}
112 139
                 </button>

+ 2
- 1
react/features/calendar-sync/components/CalendarList.web.js View File

@@ -202,7 +202,8 @@ class CalendarList extends AbstractPage<Props> {
202 202
                     className = 'meetings-list-empty-button'
203 203
                     onClick = { this._onOpenSettings }
204 204
                     onKeyPress = { this._onKeyPressOpenSettings }
205
-                    role = 'button'>
205
+                    role = 'button'
206
+                    tabIndex = { 0 }>
206 207
                     <Icon
207 208
                         className = 'meetings-list-empty-icon'
208 209
                         src = { IconCalendar } />

+ 23
- 18
react/features/chat/components/web/Chat.js View File

@@ -134,35 +134,38 @@ class Chat extends AbstractChat<Props> {
134 134
     _renderChat() {
135 135
         const { _isPollsEnabled, _isPollsTabFocused } = this.props;
136 136
 
137
-        if (_isPollsTabFocused) {
138
-            return (
139
-                <>
140
-                    { _isPollsEnabled && this._renderTabs() }
141
-                    <div
142
-                        aria-labelledby = { CHAT_TABS.POLLS }
143
-                        id = 'polls-panel'
144
-                        role = 'tabpanel'>
145
-                        <PollsPane />
146
-                    </div>
147
-                    <KeyboardAvoider />
148
-                </>
149
-            );
150
-        }
151
-
152 137
         return (
153 138
             <>
154 139
                 { _isPollsEnabled && this._renderTabs() }
155 140
                 <div
156 141
                     aria-labelledby = { CHAT_TABS.CHAT }
157
-                    className = { clsx('chat-panel', !_isPollsEnabled && 'chat-panel-no-tabs') }
158
-                    id = 'chat-panel'
159
-                    role = 'tabpanel'>
142
+                    className = { clsx(
143
+                        'chat-panel',
144
+                        !_isPollsEnabled && 'chat-panel-no-tabs',
145
+                        _isPollsTabFocused && 'hide'
146
+                    ) }
147
+                    id = { `${CHAT_TABS.CHAT}-panel` }
148
+                    role = 'tabpanel'
149
+                    tabIndex = { 0 }>
160 150
                     <MessageContainer
161 151
                         messages = { this.props._messages } />
162 152
                     <MessageRecipient />
163 153
                     <ChatInput
164 154
                         onSend = { this._onSendMessage } />
165 155
                 </div>
156
+                { _isPollsEnabled && (
157
+                    <>
158
+                        <div
159
+                            aria-labelledby = { CHAT_TABS.POLLS }
160
+                            className = { clsx('polls-panel', !_isPollsTabFocused && 'hide') }
161
+                            id = { `${CHAT_TABS.POLLS}-panel` }
162
+                            role = 'tabpanel'
163
+                            tabIndex = { 0 }>
164
+                            <PollsPane />
165
+                        </div>
166
+                        <KeyboardAvoider />
167
+                    </>
168
+                )}
166 169
             </>
167 170
         );
168 171
     }
@@ -185,11 +188,13 @@ class Chat extends AbstractChat<Props> {
185 188
                     accessibilityLabel: t('chat.tabs.chat'),
186 189
                     countBadge: _isPollsTabFocused && _nbUnreadMessages > 0 ? _nbUnreadMessages : undefined,
187 190
                     id: CHAT_TABS.CHAT,
191
+                    controlsId: `${CHAT_TABS.CHAT}-panel`,
188 192
                     label: t('chat.tabs.chat')
189 193
                 }, {
190 194
                     accessibilityLabel: t('chat.tabs.polls'),
191 195
                     countBadge: !_isPollsTabFocused && _nbUnreadPolls > 0 ? _nbUnreadPolls : undefined,
192 196
                     id: CHAT_TABS.POLLS,
197
+                    controlsId: `${CHAT_TABS.POLLS}-panel`,
193 198
                     label: t('chat.tabs.polls')
194 199
                 }
195 200
                 ] } />

+ 0
- 1
react/features/chat/components/web/ChatInput.tsx View File

@@ -115,7 +115,6 @@ class ChatInput extends Component<IProps, IState> {
115 115
                         </div>
116 116
                     )}
117 117
                     <Input
118
-                        autoFocus = { true }
119 118
                         className = 'chat-input'
120 119
                         icon = { this.props._areSmileysDisabled ? undefined : IconFaceSmile }
121 120
                         iconClick = { this._toggleSmileysPanel }

+ 26
- 12
react/features/desktop-picker/components/DesktopPicker.tsx View File

@@ -191,7 +191,7 @@ class DesktopPicker extends PureComponent<IProps, IState> {
191 191
      * @inheritdoc
192 192
      */
193 193
     render() {
194
-        const { selectedTab, selectedSource, sources } = this.state;
194
+        const { selectedTab, selectedSource, sources, types } = this.state;
195 195
 
196 196
         return (
197 197
             <Dialog
@@ -204,14 +204,27 @@ class DesktopPicker extends PureComponent<IProps, IState> {
204 204
                 size = 'large'
205 205
                 titleKey = 'dialog.shareYourScreen'>
206 206
                 { this._renderTabs() }
207
-                <DesktopPickerPane
208
-                    key = { selectedTab }
209
-                    onClick = { this._onPreviewClick }
210
-                    onDoubleClick = { this._onSubmit }
211
-                    onShareAudioChecked = { this._onShareAudioChecked }
212
-                    selectedSourceId = { selectedSource.id }
213
-                    sources = { sources[selectedTab as keyof typeof sources] }
214
-                    type = { selectedTab } />
207
+                {types.map(type => (
208
+                    <div
209
+                        aria-labelledby = { `${type}-button` }
210
+                        className = { selectedTab === type ? undefined : 'hide' }
211
+                        id = { `${type}-panel` }
212
+                        key = { type }
213
+                        role = 'tabpanel'
214
+                        tabIndex = { 0 }>
215
+                        {selectedTab === type && (
216
+                            <DesktopPickerPane
217
+                                key = { selectedTab }
218
+                                onClick = { this._onPreviewClick }
219
+                                onDoubleClick = { this._onSubmit }
220
+                                onShareAudioChecked = { this._onShareAudioChecked }
221
+                                selectedSourceId = { selectedSource.id }
222
+                                sources = { sources[selectedTab as keyof typeof sources] }
223
+                                type = { selectedTab } />
224
+                        )}
225
+                    </div>
226
+                ))}
227
+
215 228
             </Dialog>
216 229
         );
217 230
     }
@@ -348,17 +361,18 @@ class DesktopPicker extends PureComponent<IProps, IState> {
348 361
                 type => {
349 362
                     return {
350 363
                         accessibilityLabel: t(TAB_LABELS[type as keyof typeof TAB_LABELS]),
351
-                        id: type,
364
+                        id: `${type}-tab`,
365
+                        controlsId: `${type}-panel`,
352 366
                         label: t(TAB_LABELS[type as keyof typeof TAB_LABELS])
353 367
                     };
354 368
                 });
355 369
 
356 370
         return (
357 371
             <Tabs
358
-                accessibilityLabel = ''
372
+                accessibilityLabel = { t('dialog.sharingTabs') }
359 373
                 className = 'desktop-picker-tabs-container'
360 374
                 onChange = { this._onTabSelected }
361
-                selected = { this.state.selectedTab }
375
+                selected = { `${this.state.selectedTab}-tab` }
362 376
                 tabs = { tabs } />);
363 377
     }
364 378
 

+ 0
- 1
react/features/polls/components/web/PollsPane.tsx View File

@@ -40,7 +40,6 @@ const PollsPane = ({ createMode, onCreate, setCreateMode, t }: AbstractProps) =>
40 40
             <div className = { classes.footer }>
41 41
                 <Button
42 42
                     accessibilityLabel = { t('polls.create.create') }
43
-                    autoFocus = { true }
44 43
                     fullWidth = { true }
45 44
                     labelKey = { 'polls.create.create' }
46 45
                     onClick = { onCreate } />

+ 0
- 76
react/features/welcome/components/Tab.js View File

@@ -1,76 +0,0 @@
1
-// @flow
2
-import React, { Component } from 'react';
3
-
4
-/**
5
- * The type of the React {@code Component} props of {@link Tab}.
6
- */
7
-type Props = {
8
-
9
-    /**
10
-     * The index of the tab.
11
-     */
12
-    index: number,
13
-
14
-    /**
15
-     * Indicates if the tab is selected or not.
16
-     */
17
-    isSelected: boolean,
18
-
19
-    /**
20
-     * The label of the tab.
21
-     */
22
-    label: string,
23
-
24
-    /**
25
-     * Handler for selecting the tab.
26
-     */
27
-    onSelect: Function
28
-}
29
-
30
-/**
31
- * A React component that implements tabs.
32
- *
33
- */
34
-export default class Tab extends Component<Props> {
35
-    /**
36
-     * Initializes a new {@code Tab} instance.
37
-     *
38
-     * @inheritdoc
39
-     */
40
-    constructor(props: Props) {
41
-        super(props);
42
-
43
-        this._onSelect = this._onSelect.bind(this);
44
-    }
45
-
46
-    _onSelect: () => void;
47
-
48
-    /**
49
-     * Selects a tab.
50
-     *
51
-     * @returns {void}
52
-     */
53
-    _onSelect() {
54
-        const { index, onSelect } = this.props;
55
-
56
-        onSelect(index);
57
-    }
58
-
59
-    /**
60
-     * Implements the React Components's render method.
61
-     *
62
-     * @inheritdoc
63
-     */
64
-    render() {
65
-        const { index, isSelected, label } = this.props;
66
-        const className = `tab${isSelected ? ' selected' : ''}`;
67
-
68
-        return (
69
-            <div
70
-                className = { className }
71
-                key = { index }
72
-                onClick = { this._onSelect }>
73
-                { label }
74
-            </div>);
75
-    }
76
-}

+ 85
- 48
react/features/welcome/components/Tabs.js View File

@@ -1,7 +1,5 @@
1 1
 // @flow
2
-import React, { Component } from 'react';
3
-
4
-import Tab from './Tab';
2
+import React, { useCallback, useEffect, useState } from 'react';
5 3
 
6 4
 /**
7 5
  * The type of the React {@code Component} props of {@link Tabs}.
@@ -9,14 +7,10 @@ import Tab from './Tab';
9 7
 type Props = {
10 8
 
11 9
     /**
12
-     * Handler for selecting the tab.
13
-     */
14
-    onSelect: Function,
15
-
16
-    /**
17
-     * The index of the selected tab.
10
+     * Accessibility label for the tabs container.
11
+     *
18 12
      */
19
-    selected: number,
13
+    accessibilityLabel: string,
20 14
 
21 15
     /**
22 16
      * Tabs information.
@@ -27,44 +21,87 @@ type Props = {
27 21
 /**
28 22
  * A React component that implements tabs.
29 23
  *
24
+ * @returns {ReactElement} The component.
30 25
  */
31
-export default class Tabs extends Component<Props> {
32
-    static defaultProps = {
33
-        tabs: [],
34
-        selected: 0
35
-    };
26
+const Tabs = ({ accessibilityLabel, tabs }: Props) => {
27
+    const [ current, setCurrent ] = useState(0);
36 28
 
37
-    /**
38
-     * Implements the React Components's render method.
39
-     *
40
-     * @inheritdoc
41
-     */
42
-    render() {
43
-        const { onSelect, selected, tabs } = this.props;
44
-        const { content = null } = tabs.length
45
-            ? tabs[Math.min(selected, tabs.length - 1)]
46
-            : {};
29
+    const onClick = useCallback(index => event => {
30
+        event.preventDefault();
31
+        setCurrent(index);
32
+    }, []);
33
+
34
+    const onKeyDown = useCallback(index => event => {
35
+        let newIndex = null;
36
+
37
+        if (event.key === 'ArrowLeft') {
38
+            event.preventDefault();
39
+            newIndex = index === 0 ? tabs.length - 1 : index - 1;
40
+        }
41
+
42
+        if (event.key === 'ArrowRight') {
43
+            event.preventDefault();
44
+            newIndex = index === tabs.length - 1 ? 0 : index + 1;
45
+        }
46
+
47
+        if (newIndex !== null) {
48
+            setCurrent(newIndex);
49
+        }
50
+    }, [ tabs ]);
51
+
52
+    useEffect(() => {
53
+        // this test is needed to make sure the effect is triggered because of user actually changing tab
54
+        if (document.activeElement?.getAttribute('role') === 'tab') {
55
+            document.querySelector(`#${`${tabs[current].id}-tab`}`)?.focus();
56
+        }
57
+
58
+    }, [ current, tabs ]);
59
+
60
+    return (
61
+        <div className = 'tab-container'>
62
+            { tabs.length > 1
63
+                ? (
64
+                    <>
65
+                        <div
66
+                            aria-label = { accessibilityLabel }
67
+                            className = 'tab-buttons'
68
+                            role = 'tablist'>
69
+                            {tabs.map((tab, index) => (
70
+                                <button
71
+                                    aria-controls = { `${tab.id}-panel` }
72
+                                    aria-selected = { current === index ? 'true' : 'false' }
73
+                                    id = { `${tab.id}-tab` }
74
+                                    key = { tab.id }
75
+                                    onClick = { onClick(index) }
76
+                                    onKeyDown = { onKeyDown(index) }
77
+                                    role = 'tab'
78
+                                    tabIndex = { current === index ? undefined : -1 }>
79
+                                    {tab.label}
80
+                                </button>
81
+                            ))}
82
+                        </div>
83
+                        {tabs.map((tab, index) => (
84
+                            <div
85
+                                aria-labelledby = { `${tab.id}-tab` }
86
+                                className = { current === index ? 'tab-content' : 'hide' }
87
+                                id = { `${tab.id}-panel` }
88
+                                key = { tab.id }
89
+                                role = 'tabpanel'
90
+                                tabIndex = { 0 }>
91
+                                {tab.content}
92
+                            </div>
93
+                        ))}
94
+                    </>
95
+                )
96
+                : (
97
+                    <>
98
+                        <h2 className = 'sr-only'>{accessibilityLabel}</h2>
99
+                        <div className = 'tab-content'>{tabs[0].content}</div>
100
+                    </>
101
+                )
102
+            }
103
+        </div>
104
+    );
105
+};
47 106
 
48
-        return (
49
-            <div className = 'tab-container'>
50
-                { tabs.length > 1 ? (
51
-                    <div className = 'tab-buttons'>
52
-                        {
53
-                            tabs.map((tab, index) => (
54
-                                <Tab
55
-                                    index = { index }
56
-                                    isSelected = { index === selected }
57
-                                    key = { index }
58
-                                    label = { tab.label }
59
-                                    onSelect = { onSelect } />
60
-                            ))
61
-                        }
62
-                    </div>) : null
63
-                }
64
-                <div className = 'tab-content'>
65
-                    { content }
66
-                </div>
67
-            </div>
68
-        );
69
-    }
70
-}
107
+export default Tabs;

+ 6
- 18
react/features/welcome/components/WelcomePage.web.js View File

@@ -49,8 +49,7 @@ class WelcomePage extends AbstractWelcomePage {
49 49
             ...this.state,
50 50
 
51 51
             generateRoomnames:
52
-                interfaceConfig.GENERATE_ROOMNAMES_ON_WELCOME_PAGE,
53
-            selectedTab: 0
52
+                interfaceConfig.GENERATE_ROOMNAMES_ON_WELCOME_PAGE
54 53
         };
55 54
 
56 55
         /**
@@ -114,7 +113,6 @@ class WelcomePage extends AbstractWelcomePage {
114 113
         this._setRoomInputRef = this._setRoomInputRef.bind(this);
115 114
         this._setAdditionalToolbarContentRef
116 115
             = this._setAdditionalToolbarContentRef.bind(this);
117
-        this._onTabSelected = this._onTabSelected.bind(this);
118 116
         this._renderFooter = this._renderFooter.bind(this);
119 117
     }
120 118
 
@@ -326,18 +324,6 @@ class WelcomePage extends AbstractWelcomePage {
326 324
         super._onRoomChange(event.target.value);
327 325
     }
328 326
 
329
-    /**
330
-     * Callback invoked when the desired tab to display should be changed.
331
-     *
332
-     * @param {number} tabIndex - The index of the tab within the array of
333
-     * displayed tabs.
334
-     * @private
335
-     * @returns {void}
336
-     */
337
-    _onTabSelected(tabIndex) {
338
-        this.setState({ selectedTab: tabIndex });
339
-    }
340
-
341 327
     /**
342 328
      * Renders the footer.
343 329
      *
@@ -405,6 +391,7 @@ class WelcomePage extends AbstractWelcomePage {
405 391
 
406 392
         if (_calendarEnabled) {
407 393
             tabs.push({
394
+                id: 'calendar',
408 395
                 label: t('welcomepage.upcomingMeetings'),
409 396
                 content: <CalendarList />
410 397
             });
@@ -412,6 +399,7 @@ class WelcomePage extends AbstractWelcomePage {
412 399
 
413 400
         if (_recentListEnabled) {
414 401
             tabs.push({
402
+                id: 'recent',
415 403
                 label: t('welcomepage.recentMeetings'),
416 404
                 content: <RecentList />
417 405
             });
@@ -423,9 +411,9 @@ class WelcomePage extends AbstractWelcomePage {
423 411
 
424 412
         return (
425 413
             <Tabs
426
-                onSelect = { this._onTabSelected }
427
-                selected = { this.state.selectedTab }
428
-                tabs = { tabs } />);
414
+                accessibilityLabel = { t('welcomepage.meetingsAccessibilityLabel') }
415
+                tabs = { tabs } />
416
+        );
429 417
     }
430 418
 
431 419
     /**

Loading…
Cancel
Save